From 6c268067f45e732aeda6a081fabd0df5da59017e Mon Sep 17 00:00:00 2001 From: sharthak18 <157966738+sharthak18@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:14:34 +0000 Subject: [PATCH 01/10] Add RTMP/RTMPS Go Live screen streaming and bump to v2.1.0 Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/android-apk.yml | 6 +- README.md | 58 ++++++- app/build.gradle.kts | 7 +- .../screenrecorder/CaptureContract.kt | 1 + .../screenrecorder/CaptureIntents.kt | 4 +- .../screenkit/screenrecorder/MainActivity.kt | 22 ++- .../services/FloatingOverlayService.kt | 19 ++- .../services/ScreenCaptureService.kt | 130 ++++++++++++--- .../services/ScreenRecorderEngine.kt | 47 +++++- .../streaming/LiveStreamConfig.kt | 56 +++++++ .../streaming/LiveStreamSession.kt | 21 +++ .../screenrecorder/streaming/RtmpPublisher.kt | 86 ++++++++++ .../screenrecorder/ui/CaptureViewModel.kt | 1 + .../screenrecorder/ui/LiveStreamCard.kt | 150 ++++++++++++++++++ .../screenkit/screenrecorder/ui/MainScreen.kt | 12 +- .../streaming/LiveStreamConfigTest.kt | 48 ++++++ docs/live-stream-testing.md | 33 ++++ settings.gradle.kts | 3 + 18 files changed, 654 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/com/screenkit/screenrecorder/streaming/LiveStreamConfig.kt create mode 100644 app/src/main/java/com/screenkit/screenrecorder/streaming/LiveStreamSession.kt create mode 100644 app/src/main/java/com/screenkit/screenrecorder/streaming/RtmpPublisher.kt create mode 100644 app/src/main/java/com/screenkit/screenrecorder/ui/LiveStreamCard.kt create mode 100644 app/src/test/java/com/screenkit/screenrecorder/streaming/LiveStreamConfigTest.kt create mode 100644 docs/live-stream-testing.md diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index b29f2b8..994bf85 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -42,7 +42,7 @@ jobs: id: build run: | set -o pipefail - gradle --no-daemon :app:assembleDebug 2>&1 | tee /tmp/gradle-build.log + gradle --no-daemon :app:testDebugUnitTest :app:assembleDebug 2>&1 | tee /tmp/gradle-build.log - name: Post Gradle diagnostics to the temporary issue on failure if: failure() && steps.build.outcome == 'failure' @@ -81,8 +81,8 @@ jobs: - name: Publish APK to GitHub Release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.0.0' }} - name: ScreenKit ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.0.0' }} + tag_name: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.1.0' }} + name: ScreenKit ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.1.0' }} make_latest: true generate_release_notes: true files: ScreenKit.apk diff --git a/README.md b/README.md index ff69f77..180b116 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ -# ScreenKit (v2.0.0) +# ScreenKit (v2.1.0) -A native, high-performance Android capture, creator studio & screen automation suite built with Modern Android Architecture (Jetpack Compose, CameraX, Foreground Services, Accessibility Gesture Engine, MediaProjection, OpenGL ES, MediaMuxer/MediaExtractor, and MediaCodec). ScreenKit v2.0.0 delivers screen recording with **Pause & Resume**, custom area cropping, **Scrolling Long Screenshots ("Stitch Full Page")**, built-in **MP4 Video Trimmer, Animated GIF Maker & PNG Frame Extractor**, sequential multi-target **Auto Clicking (`1 → 2 → 3 → 4…`)**, directional **Auto Scrolling (`↑ / ↓ / ← / →`)**, **Touch Macro Recorder & Replay**, **Saved Auto-Clicker Profiles (Slots 1–3)**, **Anti-Detection Human Jitter**, **Smart Color / Pixel Trigger (`#HEX`)**, **Floating Teleprompter**, **On-Screen Magnifier Loupe & Color Eyedropper**, **Creator Watermark Badge**, **Shake / Volume-Down Emergency Stop**, **Live Usage Analytics & One-Tap In-App Updater**, live interactive brush annotation, on-screen touch visualization, a draggable floating camera lens, amplified sound recording (Mic / Device Audio / Mixed), and a built-in Black-Screen Bypass Web Capture Browser. +A native, high-performance Android capture, creator studio & screen automation suite built with Modern Android Architecture (Jetpack Compose, CameraX, Foreground Services, Accessibility Gesture Engine, MediaProjection, OpenGL ES, MediaMuxer/MediaExtractor, and MediaCodec). ScreenKit v2.1.0 delivers screen recording with **Pause & Resume**, custom area cropping, **Scrolling Long Screenshots ("Stitch Full Page")**, built-in **MP4 Video Trimmer, Animated GIF Maker & PNG Frame Extractor**, sequential multi-target **Auto Clicking (`1 → 2 → 3 → 4…`)**, directional **Auto Scrolling (`↑ / ↓ / ← / →`)**, **Touch Macro Recorder & Replay**, **Saved Auto-Clicker Profiles (Slots 1–3)**, **Anti-Detection Human Jitter**, **Smart Color / Pixel Trigger (`#HEX`)**, **Floating Teleprompter**, **On-Screen Magnifier Loupe & Color Eyedropper**, **Creator Watermark Badge**, **Shake / Volume-Down Emergency Stop**, **Live Usage Analytics & One-Tap In-App Updater**, live interactive brush annotation, on-screen touch visualization, a draggable floating camera lens, amplified sound recording (Mic / Device Audio / Mixed), and a built-in Black-Screen Bypass Web Capture Browser. --- ## Download and Install -[⬇️ **Download the latest ScreenKit v2.0.0 APK**](https://github.com/sharthak18/ScreenKit/releases/latest/download/ScreenKit.apk) +[⬇️ **Download the latest ScreenKit v2.1.0 APK**](https://github.com/sharthak18/ScreenKit/releases/latest/download/ScreenKit.apk) -- **Current Version**: `v2.0.0` (`versionCode = 7`) +- **Current Version**: `v2.1.0` (`versionCode = 7`) - Compatible with **Android 7.0 (API 24) to Android 15 (API 35)**. - Device Audio & Mixed (`Mic + Device`) playback capture supported on **Android 10+**. - Full screen scope and single-app exclusion selection supported on **Android 14+**. @@ -16,7 +16,7 @@ A native, high-performance Android capture, creator studio & screen automation s --- -## What's New in v2.0.0 (13 Major Features) +## What's New in v2.1.0 (13 Major Features) 1. **Touch Macro Recorder ("Record My Actions & Replay")**: Tap `● Macro` in the Auto tool, press `● Rec`, perform any real-time sequence of finger taps and swipes across the screen, tap `■ Done`, and replay your exact touch choreography (`1×` to `Infinite ∞`). 2. **Saved Auto-Clicker Profiles & Presets (Slots 1–3)**: Save and load named pin positions, delays, speeds, and modes into 3 instant profile slots (pre-loaded with **TikTok/Reels Scroll**, **Game Farming 1→4**, and **Reader Slow Scroll**). @@ -142,3 +142,51 @@ A native, high-performance Android capture, creator studio & screen automation s ## License This project is licensed under the Apache License 2.0. See the LICENSE file for details. + +## Go Live — v2.1.0 + +ScreenKit can send your **screen** as H.264 video and AAC audio to a single RTMP/RTMPS +server. This is an encoder connection, not YouTube/Facebook OAuth or a guarantee +that your account has live-streaming access. + +1. In YouTube Live Control Room, Facebook Live Producer, or another compatible + platform, create a live broadcast and select streaming/encoder software. +2. In ScreenKit's **Go Live** card choose a destination. Paste the exact **server + URL** and **stream key** from that platform as separate fields. Presets are + editable starting points. Prefer `rtmps://` (TLS) over unencrypted `rtmp://`. +3. Choose 480p/720p/1080p, 24/30/60 fps, a video bitrate (500–12000 kbps), and sound: + off, microphone, device audio, or mixed. Resolution preserves screen aspect ratio, + does not upscale, and is capped at a 1920-pixel long edge; actual frame rate depends + on your device and screen updates. A good starting point is 720p / 30 fps / 2500 kbps. +4. Enable Do Not Disturb, tap **Go Live**, review the privacy warning, and approve + Android screen sharing. Everything visible in the chosen screen/app can be sent, + including overlays and notifications. Protected/DRM content cannot be streamed. +5. Wait for **LIVE · Sending to server**, then check the platform's preview. You may + need to click **Go Live** there to make it public. ScreenKit's timer and outgoing + bitrate describe the encoder connection, not viewer count or public visibility. +6. **Mute audio** silences both microphone and device sound while preserving the AAC + track; unmute restores the sources chosen at startup. Use **End stream**, the + floating red LIVE bubble's Stop control, or the capture notification to stop. + Android's stop-sharing action and enabled shake/volume stop shortcuts also stop it. + +### Streaming limitations and privacy + +- Screen streaming is exclusive with screen recording, screenshots, and camera recording. + This version does not save a simultaneous local MP4, stream the camera as a separate source, + or send to multiple destinations at once. A visible camera overlay can appear in screen sharing. +- Sound sources are selected before starting. Device playback capture needs Android 10+ + and a source app that permits it. Denying audio permission gives a silent stream. +- Watermarks and the teleprompter are ordinary screen overlays: if included in the shared + screen, viewers see them too. They are not private presenter-only layers. +- Keys/destinations are held in process memory only, not saved in preferences, bundles, + backups, or analytics. The key field is masked and cleared when starting. Re-enter the key + for the next broadcast. Never share keys in screenshots, bug reports, or chat. +- A 25-second connection timeout, bounded outgoing queue, and server disconnect/error + handling end failed sessions and release screen/audio capture. Reconnection is manual; + no stream silently restarts after a process death or network failure. +- Network throughput, thermal limits, codec support, and platform account eligibility affect + compatibility. Test with a private/unlisted broadcast before going public. + +Transport uses the Apache-2.0 licensed [RootEncoder RTMP module, version 2.5.3](https://github.com/pedroSG94/RootEncoder/tree/2.5.3/rtmp), +pinned for compatibility with this project's Kotlin toolchain. ScreenKit reuses its own +MediaCodec screen encoder and mixed-audio capture pipeline; streaming does not write a temporary MP4. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 22d3730..124b61b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.screenkit.screenrecorder" minSdk = 24 targetSdk = 34 - versionCode = 7 - versionName = "2.0.0" + versionCode = 8 + versionName = "2.1.0" } compileOptions { @@ -31,6 +31,9 @@ android { } dependencies { + // Transport only; reuse ScreenKit's H.264/AAC screen and mixed-audio encoders. + implementation("com.github.pedroSG94.RootEncoder:rtmp:2.5.3") + testImplementation("junit:junit:4.13.2") implementation(platform("androidx.compose:compose-bom:2024.12.01")) implementation("androidx.activity:activity-compose:1.9.3") implementation("androidx.compose.ui:ui") diff --git a/app/src/main/java/com/screenkit/screenrecorder/CaptureContract.kt b/app/src/main/java/com/screenkit/screenrecorder/CaptureContract.kt index f49dd71..0ac8e51 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/CaptureContract.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/CaptureContract.kt @@ -61,6 +61,7 @@ object CaptureContract { /** Capture modes handled by [CaptureRequestActivity]. */ const val MODE_SCREENSHOT = "screenshot" const val MODE_LONG_SCREENSHOT = "long_screenshot" + const val MODE_LIVE_STREAM = "live_stream" const val MODE_SCREEN_RECORDING = "screen_recording" const val MODE_CAMERA_RECORDING = "camera_recording" const val MODE_CAMERA_PREVIEW = "camera_preview" diff --git a/app/src/main/java/com/screenkit/screenrecorder/CaptureIntents.kt b/app/src/main/java/com/screenkit/screenrecorder/CaptureIntents.kt index 8536b70..75a1fe6 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/CaptureIntents.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/CaptureIntents.kt @@ -50,6 +50,7 @@ internal object CaptureIntents { CaptureContract.MODE_SCREENSHOT, CaptureContract.MODE_PARTIAL_SCREENSHOT -> ScreenCaptureService.ACTION_SCREENSHOT CaptureContract.MODE_LONG_SCREENSHOT -> ScreenCaptureService.ACTION_LONG_SCREENSHOT + CaptureContract.MODE_LIVE_STREAM -> ScreenCaptureService.ACTION_START_LIVE else -> ScreenCaptureService.ACTION_START_RECORDING }, ) @@ -162,10 +163,11 @@ internal object CaptureIntents { ) { required += Manifest.permission.POST_NOTIFICATIONS } - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P && !canWriteExternalStorage(context)) { + if (mode != CaptureContract.MODE_LIVE_STREAM && Build.VERSION.SDK_INT <= Build.VERSION_CODES.P && !canWriteExternalStorage(context)) { required += Manifest.permission.WRITE_EXTERNAL_STORAGE } when (mode) { + CaptureContract.MODE_LIVE_STREAM, CaptureContract.MODE_SCREEN_RECORDING, CaptureContract.MODE_PARTIAL_SCREEN_RECORDING -> { if (SettingsStore.audioMode(context).requiresRecordPermission && diff --git a/app/src/main/java/com/screenkit/screenrecorder/MainActivity.kt b/app/src/main/java/com/screenkit/screenrecorder/MainActivity.kt index 71d42a1..993a8bc 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/MainActivity.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/MainActivity.kt @@ -37,6 +37,8 @@ import com.screenkit.screenrecorder.data.UpdateAndStatsManager import com.screenkit.screenrecorder.services.BlackScreenBypass import com.screenkit.screenrecorder.services.CameraCaptureService import com.screenkit.screenrecorder.services.FloatingOverlayService +import com.screenkit.screenrecorder.streaming.LiveStreamConfig +import com.screenkit.screenrecorder.streaming.LiveStreamSession import com.screenkit.screenrecorder.services.ScreenCaptureService import com.screenkit.screenrecorder.services.ScreenKitAutoService import com.screenkit.screenrecorder.services.ShakeAndKeyStopDetector @@ -83,6 +85,7 @@ class MainActivity : ComponentActivity() { screenSessionActive = active, screenRecording = active && recording, screenPaused = active && recording && paused, + liveStreaming = active && mode == "live", screenshotInProgress = active && mode == "screenshot", screenRecordingStartedAt = if (active && recording) startedAt else 0L, ) @@ -174,12 +177,14 @@ class MainActivity : ComponentActivity() { if (result.resultCode != Activity.RESULT_OK || data == null || mode == null) { screenshotFlowPending = false FloatingOverlayService.setHiddenForScreenshot(this, false) + LiveStreamSession.pending = null showMessage("Screen capture was cancelled.") return@registerForActivityResult } if (!CaptureIntents.startScreenCapture(this, mode, result.resultCode, data, crop)) { screenshotFlowPending = false FloatingOverlayService.setHiddenForScreenshot(this, false) + LiveStreamSession.pending = null showMessage("Could not start the screen capture. Please try again.") } } @@ -239,6 +244,14 @@ class MainActivity : ComponentActivity() { onTakeScreenshot = { requestScreenCapture(CaptureContract.MODE_SCREENSHOT) }, onTakePartialScreenshot = { requestPartialScreenCapture(CaptureContract.MODE_PARTIAL_SCREENSHOT) }, onTakeLongScreenshot = { requestScreenCapture(CaptureContract.MODE_LONG_SCREENSHOT) }, + onStartLive = { config -> + if (!ScreenCaptureService.isActive && !CameraCaptureService.isRecording) { + LiveStreamSession.pending = config + requestScreenCapture(CaptureContract.MODE_LIVE_STREAM) + } else showMessage("Finish the active capture before going live.") + }, + onStopLive = { stopScreenRecording() }, + onMuteLive = { startService(Intent(this, ScreenCaptureService::class.java).setAction(ScreenCaptureService.ACTION_MUTE_LIVE)) }, onToggleScreenRecording = { toggleScreenRecording() }, onPauseResumeScreenRecording = { CaptureIntents.togglePauseScreenCapture(this) @@ -593,6 +606,7 @@ class MainActivity : ComponentActivity() { screenSessionActive = screenActive, screenRecording = screenActive && screenRecording, screenPaused = screenActive && screenRecording && screenPaused, + liveStreaming = screenActive && ScreenCaptureService.isLive, screenshotInProgress = screenActive && screenMode == "screenshot", screenRecordingStartedAt = if (screenRecording) ScreenCaptureService.recordingStartedAt else 0L, cameraSessionActive = cameraActive, @@ -681,14 +695,14 @@ class MainActivity : ComponentActivity() { } private fun continueScreenCaptureAfterPermissions(mode: String) { - if (!CaptureIntents.canWriteExternalStorage(this)) { + if (mode != CaptureContract.MODE_LIVE_STREAM && !CaptureIntents.canWriteExternalStorage(this)) { pendingProjectionMode = null screenshotFlowPending = false FloatingOverlayService.setHiddenForScreenshot(this, false) showMessage("Storage permission is needed to save captures on this Android version.") return } - if ((mode == CaptureContract.MODE_SCREEN_RECORDING || mode == CaptureContract.MODE_PARTIAL_SCREEN_RECORDING) && + if ((mode == CaptureContract.MODE_LIVE_STREAM || mode == CaptureContract.MODE_SCREEN_RECORDING || mode == CaptureContract.MODE_PARTIAL_SCREEN_RECORDING) && SettingsStore.audioMode(this).requiresRecordPermission && !hasPermission(Manifest.permission.RECORD_AUDIO) ) { @@ -705,6 +719,7 @@ class MainActivity : ComponentActivity() { pendingProjectionMode = null screenshotFlowPending = false FloatingOverlayService.setHiddenForScreenshot(this, false) + LiveStreamSession.pending = null showMessage("Screen capture is not available on this device.") return } @@ -713,6 +728,7 @@ class MainActivity : ComponentActivity() { pendingProjectionMode = null screenshotFlowPending = false FloatingOverlayService.setHiddenForScreenshot(this, false) + LiveStreamSession.pending = null showMessage("Could not open Android's screen-capture approval prompt.") } } @@ -1057,6 +1073,8 @@ class MainActivity : ComponentActivity() { continueScreenCaptureAfterPermissions(CaptureContract.MODE_PARTIAL_SCREENSHOT) flow == "screen:${CaptureContract.MODE_LONG_SCREENSHOT}" -> continueScreenCaptureAfterPermissions(CaptureContract.MODE_LONG_SCREENSHOT) + flow == "screen:${CaptureContract.MODE_LIVE_STREAM}" -> + continueScreenCaptureAfterPermissions(CaptureContract.MODE_LIVE_STREAM) flow == "screen:${CaptureContract.MODE_SCREEN_RECORDING}" -> continueScreenCaptureAfterPermissions(CaptureContract.MODE_SCREEN_RECORDING) flow == "screen:${CaptureContract.MODE_PARTIAL_SCREEN_RECORDING}" -> diff --git a/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt b/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt index 4e2061e..73b22fd 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt @@ -407,7 +407,7 @@ class FloatingOverlayService : Service() { if (recordingNow) { val mark = TextView(this).apply { - text = "■" + text = if (ScreenCaptureService.isLive) "LIVE" else "■" textSize = 14f typeface = Typeface.create("sans-serif", Typeface.BOLD) setTextColor(Color.WHITE) @@ -483,7 +483,9 @@ class FloatingOverlayService : Service() { CaptureContract.COMMAND_TOGGLE_SCREEN_RECORDING, screenRecording, ), - if (screenRecording) { + if (ScreenCaptureService.isLive) { + MenuAction("●", "Live info", CaptureContract.COMMAND_OPEN_APP, true) + } else if (screenRecording) { MenuAction( if (screenPaused) "▶" else "⏸", if (screenPaused) "Resume" else "Pause", @@ -499,7 +501,10 @@ class FloatingOverlayService : Service() { CaptureContract.COMMAND_TOGGLE_CAMERA_RECORDING, cameraRecording, ), - MenuAction(soundIcon(), soundLabel(), CaptureContract.COMMAND_CYCLE_AUDIO, soundActive), + if (ScreenCaptureService.isLive) MenuAction( + "♫", if (com.screenkit.screenrecorder.streaming.LiveStreamSession.status.value.muted) "Unmute" else "Mute", + CaptureContract.COMMAND_CYCLE_AUDIO, !com.screenkit.screenrecorder.streaming.LiveStreamSession.status.value.muted, + ) else MenuAction(soundIcon(), soundLabel(), CaptureContract.COMMAND_CYCLE_AUDIO, soundActive), MenuAction("⊕", "AutoTap", CaptureContract.COMMAND_OPEN_AUTO_CLICK, autoClickActive), ), listOf( @@ -596,6 +601,10 @@ class FloatingOverlayService : Service() { * sheets execute directly inside the service so the current app stays uninterrupted. */ private fun dispatch(command: String) { + if (ScreenCaptureService.isLive && (command == CaptureContract.COMMAND_CYCLE_AUDIO || command == CaptureContract.COMMAND_TOGGLE_AUDIO)) { + startService(Intent(this, ScreenCaptureService::class.java).setAction(ScreenCaptureService.ACTION_MUTE_LIVE)) + return + } if (command == CaptureContract.COMMAND_CYCLE_AUDIO || command == CaptureContract.COMMAND_TOGGLE_AUDIO ) { @@ -708,12 +717,12 @@ class FloatingOverlayService : Service() { } CaptureContract.COMMAND_TOGGLE_SCREEN_RECORDING -> if (ScreenCaptureService.isRecording) { CaptureIntents.stopScreenCapture(this) - CaptureIntents.toast(this, "Saving the screen recording…") + CaptureIntents.toast(this, if (ScreenCaptureService.isLive) "Ending live stream…" else "Saving the screen recording…") return } CaptureContract.COMMAND_PARTIAL_SCREEN_RECORDING -> if (ScreenCaptureService.isRecording) { CaptureIntents.stopScreenCapture(this) - CaptureIntents.toast(this, "Saving the screen recording…") + CaptureIntents.toast(this, if (ScreenCaptureService.isLive) "Ending live stream…" else "Saving the screen recording…") return } CaptureContract.COMMAND_TOGGLE_CAMERA_RECORDING -> if (CameraCaptureService.isRecording) { diff --git a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenCaptureService.kt b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenCaptureService.kt index 44574a1..9ddb8d4 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenCaptureService.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenCaptureService.kt @@ -33,6 +33,10 @@ import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import com.screenkit.screenrecorder.CaptureContract import com.screenkit.screenrecorder.MainActivity +import com.screenkit.screenrecorder.streaming.LiveStreamConfig +import com.screenkit.screenrecorder.streaming.LiveStreamSession +import com.screenkit.screenrecorder.streaming.LiveStreamStatus +import com.screenkit.screenrecorder.streaming.RtmpPublisher import com.screenkit.screenrecorder.R import com.screenkit.screenrecorder.data.CaptureAudioMode import com.screenkit.screenrecorder.data.CaptureKind @@ -75,6 +79,8 @@ class ScreenCaptureService : Service() { private var recorder: MediaRecorder? = null private var recorderSurface: Surface? = null private var engine: ScreenRecorderEngine? = null + private var liveConfig: LiveStreamConfig? = null + private var publisher: RtmpPublisher? = null private var sink: CaptureSink? = null private var outputDescriptor: ParcelFileDescriptor? = null private var wakeLock: PowerManager.WakeLock? = null @@ -110,7 +116,9 @@ class ScreenCaptureService : Service() { finishSession( keepVideo = mode == Mode.VIDEO, screenshot = null, - message = if (mode == Mode.VIDEO) { + message = if (isLive) { + "Live stream ended because Android stopped screen sharing." + } else if (mode == Mode.VIDEO) { "Android stopped screen sharing. Any valid recording was saved." } else { "Screen sharing ended before a screenshot frame arrived." @@ -123,6 +131,8 @@ class ScreenCaptureService : Service() { companion object { const val ACTION_SCREENSHOT = "com.screenkit.screenrecorder.screen.SCREENSHOT" const val ACTION_LONG_SCREENSHOT = "com.screenkit.screenrecorder.screen.LONG_SCREENSHOT" + const val ACTION_START_LIVE = "com.screenkit.screenrecorder.screen.START_LIVE" + const val ACTION_MUTE_LIVE = "com.screenkit.screenrecorder.screen.MUTE_LIVE" const val ACTION_START_RECORDING = "com.screenkit.screenrecorder.screen.START_RECORDING" const val ACTION_PAUSE_RESUME = "com.screenkit.screenrecorder.screen.PAUSE_RESUME" const val ACTION_STOP = "com.screenkit.screenrecorder.screen.STOP" @@ -136,6 +146,8 @@ class ScreenCaptureService : Service() { private const val SCREENSHOT_FALLBACK_MS = 850L private const val MAX_BLACK_FRAME_RETRIES = 8 + @Volatile var isLive: Boolean = false + private set @Volatile var isActive: Boolean = false private set @Volatile var isRecording: Boolean = false @@ -161,12 +173,22 @@ class ScreenCaptureService : Service() { when (intent.action) { ACTION_STOP -> { if (sessionActive) { - finishSession(true, null, "Recording saved.", stopProjection = true) + finishSession(true, null, if (isLive) "Live stream ended." else "Recording saved.", stopProjection = true) } else { stopSelf(startId) } return START_NOT_STICKY } + ACTION_MUTE_LIVE -> { + if (sessionActive && isLive && !finishing) { + val muted = !(engine?.muted ?: false) + engine?.muted = muted + LiveStreamSession.update(LiveStreamSession.status.value.copy(muted = muted)) + updateNotification(recording = true) + broadcastState() + } else if (!sessionActive) stopSelf(startId) + return START_NOT_STICKY + } ACTION_PAUSE_RESUME -> { if (sessionActive && mode == Mode.VIDEO && isRecording) { togglePauseResume() @@ -180,6 +202,13 @@ class ScreenCaptureService : Service() { } } if (sessionActive) return START_NOT_STICKY + if (intent.action !in listOf(ACTION_SCREENSHOT, ACTION_LONG_SCREENSHOT, ACTION_START_RECORDING, ACTION_START_LIVE)) { + stopSelf(startId) + return START_NOT_STICKY + } + isLive = intent.action == ACTION_START_LIVE + liveConfig = if (isLive) LiveStreamSession.pending else null + LiveStreamSession.pending = null isLongScreenshotMode = intent.action == ACTION_LONG_SCREENSHOT longScreenshotFrames.clear() @@ -201,7 +230,7 @@ class ScreenCaptureService : Service() { isActive = true isRecording = false - activeMode = if (mode == Mode.VIDEO) "screen" else "screenshot" + activeMode = if (isLive) "live" else if (mode == Mode.VIDEO) "screen" else "screenshot" recordingStartedAt = 0L if (mode == Mode.SCREENSHOT) { @@ -224,6 +253,11 @@ class ScreenCaptureService : Service() { try { BlackScreenBypass.trySystemSecureBypass(this) promoteToForeground(mode == Mode.VIDEO && audioMode.requiresRecordPermission && canRecordAudio()) + if (isLive) { + check(!CameraCaptureService.isRecording) { "Stop the camera recording first." } + checkNotNull(liveConfig) { "Enter the live destination again." }.validate() + LiveStreamSession.update(LiveStreamStatus(active = true, message = "Connecting…")) + } broadcastState() startApprovedCapture(intent) } catch (error: Exception) { @@ -276,7 +310,7 @@ class ScreenCaptureService : Service() { val rawCropW = if (crop != null) crop.width().toInt().coerceIn(16, fullW) else fullW val rawCropH = if (crop != null) crop.height().toInt().coerceIn(16, fullH) else fullH - val size = videoSize(rawCropW, rawCropH) + val size = liveConfig?.dimensions(rawCropW, rawCropH) ?: videoSize(rawCropW, rawCropH) check(size.first >= 2 && size.second >= 2) { "Could not read the device screen dimensions." } val uvCropRect = if (crop != null) { @@ -288,9 +322,10 @@ class ScreenCaptureService : Service() { ) } else null - val output = CaptureSink.create(this, CaptureKind.SCREEN_RECORDING, ".mp4") - ?: throw IOException("Could not create the recording in the chosen location.") - sink = output + if (!isLive) { + sink = CaptureSink.create(this, CaptureKind.SCREEN_RECORDING, ".mp4") + ?: throw IOException("Could not create the recording in the chosen location.") + } acquireWakeLock() val recordAudioGranted = canRecordAudio() @@ -303,7 +338,32 @@ class ScreenCaptureService : Service() { else -> audioMode } - if (effective != CaptureAudioMode.NONE) { + if (isLive) { + val config = checkNotNull(liveConfig) + val transport = RtmpPublisher( + onConnected = { + if (sessionActive && !finishing) { + engine?.requestKeyFrame() + recordingStartedAt = System.currentTimeMillis() + LiveStreamSession.update(LiveStreamSession.status.value.copy( + connected = true, message = "LIVE · Sending to server", startedAt = recordingStartedAt, + )) + broadcastState() + updateNotification(recording = true) + } + }, + onFailure = { message -> + if (sessionActive && !finishing) finishSession(false, null, message, stopProjection = true) + }, + onBitrate = { bitrate -> + if (sessionActive && !finishing) LiveStreamSession.update(LiveStreamSession.status.value.copy(bitrate = bitrate)) + }, + ) + publisher = transport + startEngineRecording(metrics, size, effective, uvCropRect) + transport.connect(config, size.first, size.second, hasAudio = true) + liveConfig = null // Only the transport retains its endpoint until disconnect. + } else if (effective != CaptureAudioMode.NONE) { startEngineRecording(metrics, size, effective, uvCropRect) } else { startMediaRecorderRecording(metrics, size, effective, uvCropRect) @@ -322,6 +382,7 @@ class ScreenCaptureService : Service() { } private fun togglePauseResume() { + if (isLive) return // Live timestamps cannot be paused; mute audio or end the broadcast instead. val screenEngine = engine val mediaRecorder = recorder if (!isPaused) { @@ -399,23 +460,30 @@ class ScreenCaptureService : Service() { effective: CaptureAudioMode, uvCropRect: android.graphics.RectF?, ) { - val target = sink ?: throw IOException("Could not create the recording in the chosen location.") - val descriptor = target.openDescriptorForWrite(this) + val target = sink + val descriptor = target?.openDescriptorForWrite(this) if (descriptor != null) outputDescriptor = descriptor - val file = if (descriptor == null) target.tempTarget?.also { it.parentFile?.mkdirs() } else null - if (descriptor == null && file == null) throw IOException("Could not open the recording file.") + val file = if (descriptor == null) target?.tempTarget?.also { it.parentFile?.mkdirs() } else null + if (!isLive && descriptor == null && file == null) throw IOException("Could not open the recording file.") val screenEngine = ScreenRecorderEngine( width = size.first, height = size.second, - frameRate = FRAME_RATE, - bitRate = bitrateFor(size.first, size.second), + frameRate = liveConfig?.fps ?: FRAME_RATE, + bitRate = liveConfig?.let { it.bitrateKbps * 1000 } ?: bitrateFor(size.first, size.second), audioMode = effective, projection = projection ?: error("Android did not provide a screen capture session."), descriptor = descriptor, file = file, - onAudioUnavailable = { note -> audioNote = note }, + onAudioUnavailable = { note -> + audioNote = note + if (isLive) mainHandler.post { + if (sessionActive && !finishing) android.widget.Toast.makeText(this, note, android.widget.Toast.LENGTH_LONG).show() + } + }, + publisher = publisher, ) + engine = screenEngine screenEngine.prepare() setupVirtualDisplayWithCrop(metrics, size, screenEngine.surface, uvCropRect) screenEngine.start() @@ -883,7 +951,18 @@ class ScreenCaptureService : Service() { var resultUri: Uri? = null var finalMessage = message - if (mode == Mode.VIDEO) { + val wasLive = isLive + if (wasLive) { + publisher?.close() + publisher = null + liveConfig = null + LiveStreamSession.pending = null + virtualDisplay?.let { runCatching { it.release() } } + virtualDisplay = null + engine?.abort() + engine = null + LiveStreamSession.update(LiveStreamStatus(message = finalMessage)) + } else if (mode == Mode.VIDEO) { var finalized = false val screenEngine = engine if (screenEngine != null) { @@ -937,6 +1016,7 @@ class ScreenCaptureService : Service() { releaseWakeLock() sessionActive = false isActive = false + isLive = false isRecording = false isPaused = false activeMode = "" @@ -1040,9 +1120,10 @@ class ScreenCaptureService : Service() { ) val builder = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_capture) - .setContentTitle(if (recording) "Screen recording" else "Screen capture") + .setContentTitle(if (isLive) "ScreenKit Live" else if (recording) "Screen recording" else "Screen capture") .setContentText( - if (recording) "${elapsedText()} · Tap Stop to save the MP4." else "Saving a screenshot…", + if (isLive) "${LiveStreamSession.status.value.message} · ${elapsedText()}" + else if (recording) "${elapsedText()} · Tap Stop to save the MP4." else "Saving a screenshot…", ) .setContentIntent(contentIntent) .setCategory(NotificationCompat.CATEGORY_SERVICE) @@ -1050,16 +1131,17 @@ class ScreenCaptureService : Service() { .setOnlyAlertOnce(true) .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) - if (recording) { + if (recording || isLive) { val pauseResume = PendingIntent.getService( this, 403, - Intent(this, ScreenCaptureService::class.java).setAction(ACTION_PAUSE_RESUME), + Intent(this, ScreenCaptureService::class.java).setAction(if (isLive) ACTION_MUTE_LIVE else ACTION_PAUSE_RESUME), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) builder.addAction( if (isPaused) android.R.drawable.ic_media_play else android.R.drawable.ic_media_pause, - if (isPaused) "Resume" else "Pause", + if (isLive) { if (LiveStreamSession.status.value.muted) "Unmute" else "Mute audio" } + else if (isPaused) "Resume" else "Pause", pauseResume, ) val stop = PendingIntent.getService( @@ -1074,7 +1156,7 @@ class ScreenCaptureService : Service() { } private fun elapsedText(): String { - val elapsedSeconds = ((System.currentTimeMillis() - recordingStartedAt).coerceAtLeast(0L) / 1_000L) + val elapsedSeconds = if (recordingStartedAt == 0L) 0L else ((System.currentTimeMillis() - recordingStartedAt).coerceAtLeast(0L) / 1_000L) return String.format(Locale.getDefault(), "%02d:%02d", elapsedSeconds / 60, elapsedSeconds % 60) } @@ -1119,7 +1201,9 @@ class ScreenCaptureService : Service() { sendBroadcast(update) } - private fun describeStartupError(error: Exception): String = when (error) { + private fun describeStartupError(error: Exception): String = if (isLive) { + "Live streaming could not start. Re-enter the destination, accept screen sharing, or try a lower quality." + } else when (error) { is SecurityException -> "Screen capture was not approved. Accept Android's prompt and try again." is IOException -> "Could not create the capture file. Check available storage and try again." else -> "Screen capture could not start on this device. Please try again." diff --git a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenRecorderEngine.kt b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenRecorderEngine.kt index 4fa84a5..20d19b4 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenRecorderEngine.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenRecorderEngine.kt @@ -43,6 +43,7 @@ internal class ScreenRecorderEngine( private val descriptor: ParcelFileDescriptor?, private val file: File?, private val onAudioUnavailable: (String) -> Unit, + private val publisher: com.screenkit.screenrecorder.streaming.RtmpPublisher? = null, ) { private class CaptureSource( val record: AudioRecord, @@ -90,6 +91,13 @@ internal class ScreenRecorderEngine( private var lastVideoPtsUs = -1L private var lastAudioPtsUs = -1L private var prepared = false + @Volatile var muted: Boolean = false + + fun requestKeyFrame() { + runCatching { videoCodec?.setParameters(android.os.Bundle().apply { + putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0) + }) } + } /** Surface the caller feeds the display into. */ val surface: Surface get() = inputSurface ?: error("The encoder surface is not ready.") @@ -105,17 +113,22 @@ internal class ScreenRecorderEngine( setInteger(MediaFormat.KEY_BIT_RATE, bitRate) setInteger(MediaFormat.KEY_FRAME_RATE, frameRate) setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) + if (publisher != null) setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline) setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR) setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, 100_000L) } val video = MediaCodec.createEncoderByType(VIDEO_MIME) + videoCodec = video // Owned immediately so a configure/prepare failure can release it. video.configure(videoFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) val createdSurface = video.createInputSurface() + inputSurface = createdSurface video.start() videoCodec = video inputSurface = createdSurface - muxer = createMuxer() ?: throw IllegalStateException("Could not open the recording output.") + if (publisher == null) { + muxer = createMuxer() ?: throw IllegalStateException("Could not open the recording output.") + } prepared = true } @@ -150,7 +163,7 @@ internal class ScreenRecorderEngine( audioCodec?.let { codec -> audioDrain = drainLoop(codec, isVideo = false).also { it.start() } } - if (audioSources.isNotEmpty()) { + if (audioCodec != null) { mixerThread = mixerLoop().also { it.start() } } } @@ -211,7 +224,7 @@ internal class ScreenRecorderEngine( } private fun startAudioCapture() { - if (!audioMode.usesMicrophone && !audioMode.usesDeviceAudio) return + if (publisher == null && !audioMode.usesMicrophone && !audioMode.usesDeviceAudio) return if (audioMode.usesDeviceAudio && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { onAudioUnavailable("Device audio needs Android 10 or newer.") } @@ -228,6 +241,7 @@ internal class ScreenRecorderEngine( } }.getOrNull() if (codec == null) { + if (publisher != null) error("No AAC encoder is available for streaming.") onAudioUnavailable("No audio encoder is available; this recording is silent.") return } @@ -258,7 +272,7 @@ internal class ScreenRecorderEngine( if (wantsDevice && device == null) { onAudioUnavailable("Device audio was unavailable for this session.") } - if (microphone == null && device == null) { + if (microphone == null && device == null && publisher == null) { runCatching { codec.stop() } runCatching { codec.release() } if (wantsMicrophone || wantsDevice) { @@ -527,7 +541,10 @@ internal class ScreenRecorderEngine( devChunk = runCatching { dev.queue.poll(22L, TimeUnit.MILLISECONDS) }.getOrNull() micChunk = null } else { - break + // Live destinations still get a paced silent AAC track when sound is off. + runCatching { Thread.sleep(20L) } + micChunk = null + devChunk = null } when { @@ -556,7 +573,7 @@ internal class ScreenRecorderEngine( val codec = audioCodec ?: return if (audioAbandoned) return val bytes = ByteArray(length * 2) - ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(samples, 0, length) + if (!muted) ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(samples, 0, length) var offset = 0 var attempts = 0 while (captureActive.get() && offset < bytes.size) { @@ -600,14 +617,18 @@ internal class ScreenRecorderEngine( val index = runCatching { codec.dequeueOutputBuffer(info, 10_000L) } .getOrDefault(Int.MIN_VALUE) when { - index == Int.MIN_VALUE -> break + index == Int.MIN_VALUE -> { + if (captureActive.get()) publisher?.encoderError() + break + } index == MediaCodec.INFO_TRY_AGAIN_LATER -> if (isVideo) nudgeAudioStart() index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> registerTrack(codec, isVideo) index >= 0 -> { val buffer = runCatching { codec.getOutputBuffer(index) }.getOrNull() val isConfig = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0 if (buffer != null && info.size > 0 && !isConfig) { - handleSample(isVideo, buffer, info) + try { handleSample(isVideo, buffer, info) } + catch (_: Exception) { publisher?.encoderError() } } runCatching { codec.releaseOutputBuffer(index, false) } if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) endOfStream = true @@ -618,6 +639,10 @@ internal class ScreenRecorderEngine( private fun registerTrack(codec: MediaCodec, isVideo: Boolean) { val format = runCatching { codec.outputFormat }.getOrNull() ?: return + if (publisher != null) { + if (isVideo) runCatching { publisher.videoFormat(format) }.onFailure { publisher.encoderError() } + return + } synchronized(muxerLock) { val current = muxer ?: return if (muxerStarted) return @@ -675,6 +700,12 @@ internal class ScreenRecorderEngine( } val track = if (isVideo) videoTrackIndex else audioTrackIndex val ptsUs = if (isVideo) videoPtsUs(info.presentationTimeUs) else audioPtsUs(info.presentationTimeUs) + if (publisher != null) { + val out = MediaCodec.BufferInfo().apply { set(0, info.size, ptsUs, info.flags) } + val copy = buffer.duplicate().apply { position(info.offset); limit(info.offset + info.size) }.slice() + publisher.sample(isVideo, copy, out) + return + } synchronized(muxerLock) { val current = muxer ?: return if (!muxerStarted || track < 0) { diff --git a/app/src/main/java/com/screenkit/screenrecorder/streaming/LiveStreamConfig.kt b/app/src/main/java/com/screenkit/screenrecorder/streaming/LiveStreamConfig.kt new file mode 100644 index 0000000..a5a5d13 --- /dev/null +++ b/app/src/main/java/com/screenkit/screenrecorder/streaming/LiveStreamConfig.kt @@ -0,0 +1,56 @@ +package com.screenkit.screenrecorder.streaming + +import java.net.URI + +/** Deliberately not a data class: toString must never include the stream key. */ +internal class LiveStreamConfig( + val server: String, + private val streamKey: String, + val shortEdge: Int = 720, + val fps: Int = 30, + val bitrateKbps: Int = 2500, +) { + fun endpoint(): String = validateEndpoint(server, streamKey) + + fun validate() { + endpoint() + require(shortEdge in listOf(480, 720, 1080)) { "Choose 480p, 720p, or 1080p." } + require(fps in listOf(24, 30, 60)) { "Choose 24, 30, or 60 fps." } + require(bitrateKbps in 500..12000) { "Bitrate must be 500–12000 kbps." } + } + + fun dimensions(width: Int, height: Int): Pair { + require(width > 0 && height > 0) + // Preserve screen aspect ratio, avoid upscaling, keep encoder dimensions even. + val scale = minOf(1.0, shortEdge.toDouble() / minOf(width, height), 1920.0 / maxOf(width, height)) + return ((width * scale).toInt() and -2).coerceAtLeast(2) to + ((height * scale).toInt() and -2).coerceAtLeast(2) + } + + companion object { + fun validateEndpoint(server: String, key: String): String { + val base = server.trim().trimEnd('/') + val secret = key.trim() + require(base.length <= 4096 && secret.length <= 4096) { "Server URL or stream key is too long." } + require(base.isNotEmpty() && secret.isNotEmpty()) { "Enter both the server URL and stream key." } + require((base + secret).none { it.isWhitespace() || it.isISOControl() }) { + "Server URL and stream key must not contain spaces or control characters." + } + val uri = runCatching { URI(base) }.getOrNull() + require(uri != null && uri.scheme in listOf("rtmp", "rtmps") && !uri.host.isNullOrBlank()) { + "Use a valid rtmp:// or rtmps:// server URL." + } + require(uri!!.rawUserInfo == null && uri.rawQuery == null && uri.rawFragment == null) { + "Paste the server URL separately, without credentials, query, or fragment." + } + require(uri.port == -1 || uri.port in 1..65535) { "Invalid server port." } + require(!uri.path.isNullOrBlank() && uri.path != "/") { "The server URL needs an application path, such as /live." } + require(!secret.startsWith('/') && !secret.contains("://") && !secret.contains('#')) { + "Paste only the stream key, not a full URL." + } + val endpoint = "$base/$secret" + require(runCatching { URI(endpoint) }.isSuccess) { "The stream key contains invalid URL characters." } + return endpoint + } + } +} diff --git a/app/src/main/java/com/screenkit/screenrecorder/streaming/LiveStreamSession.kt b/app/src/main/java/com/screenkit/screenrecorder/streaming/LiveStreamSession.kt new file mode 100644 index 0000000..fac6b35 --- /dev/null +++ b/app/src/main/java/com/screenkit/screenrecorder/streaming/LiveStreamSession.kt @@ -0,0 +1,21 @@ +package com.screenkit.screenrecorder.streaming + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +internal data class LiveStreamStatus( + val active: Boolean = false, + val connected: Boolean = false, + val message: String = "Ready to stream", + val startedAt: Long = 0L, + val bitrate: Long = 0L, + val muted: Boolean = false, +) + +/** Process-local only. Never persist destinations/keys in preferences, saved state, or backups. */ +internal object LiveStreamSession { + @Volatile var pending: LiveStreamConfig? = null + private val mutableStatus = MutableStateFlow(LiveStreamStatus()) + val status = mutableStatus.asStateFlow() + fun update(value: LiveStreamStatus) { mutableStatus.value = value } +} diff --git a/app/src/main/java/com/screenkit/screenrecorder/streaming/RtmpPublisher.kt b/app/src/main/java/com/screenkit/screenrecorder/streaming/RtmpPublisher.kt new file mode 100644 index 0000000..43bed23 --- /dev/null +++ b/app/src/main/java/com/screenkit/screenrecorder/streaming/RtmpPublisher.kt @@ -0,0 +1,86 @@ +package com.screenkit.screenrecorder.streaming + +import android.media.MediaCodec +import android.media.MediaFormat +import android.os.Handler +import android.os.Looper +import com.pedro.common.ConnectChecker +import com.pedro.rtmp.rtmp.RtmpClient +import java.nio.ByteBuffer +import java.util.concurrent.atomic.AtomicBoolean + +/** Bounded asynchronous RTMP/RTMPS transport; codec drain threads never perform socket I/O. */ +internal class RtmpPublisher( + private val onConnected: () -> Unit, + private val onFailure: (String) -> Unit, + private val onBitrate: (Long) -> Unit, +) : ConnectChecker { + private val handler = Handler(Looper.getMainLooper()) + private val closed = AtomicBoolean(false) + @Volatile private var connected = false + @Volatile private var waitingForKeyFrame = true + private val client = RtmpClient(this).apply { + setLogs(false) + setCheckServerAlive(true) + resizeCache(120) + setAudioInfo(48_000, false) + } + private val timeout = Runnable { fail("Connection timed out. Check your network, server URL, and stream key.") } + + fun connect(config: LiveStreamConfig, width: Int, height: Int, hasAudio: Boolean) { + client.setOnlyVideo(!hasAudio) + client.setVideoResolution(width, height) + client.setFps(config.fps) + handler.postDelayed(timeout, 25_000L) + client.connect(config.endpoint()) + } + + fun videoFormat(format: MediaFormat) { + val sps = format.getByteBuffer("csd-0") ?: error("H.264 configuration unavailable") + val pps = format.getByteBuffer("csd-1") ?: error("H.264 configuration unavailable") + client.setVideoInfo(sps.duplicate(), pps.duplicate(), null) + } + + fun sample(video: Boolean, buffer: ByteBuffer, info: MediaCodec.BufferInfo) { + if (closed.get() || !connected) return + if (waitingForKeyFrame) { + if (!video || info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME == 0) return + waitingForKeyFrame = false + } + // RootEncoder clones the bytes synchronously before queueing them; the codec may then release its buffer. + if (video) client.sendVideo(buffer, info) else client.sendAudio(buffer, info) + } + + fun encoderError() = fail("The audio/video encoder stopped. Lower the quality and start a new stream.") + + fun close() { + if (!closed.compareAndSet(false, true)) return + connected = false + handler.removeCallbacks(timeout) + client.disconnect() + } + + private fun fail(message: String) { + handler.post { + if (!closed.get()) { + close() + onFailure(message) + } + } + } + + override fun onConnectionStarted(url: String) = Unit // Never expose/log the URL: it includes the key. + override fun onConnectionSuccess() { + if (closed.get()) return + handler.removeCallbacks(timeout) + connected = true + handler.post { if (!closed.get()) onConnected() } + } + override fun onConnectionFailed(reason: String) = fail( + "Stream connection failed. Check your network, live dashboard, server URL, and stream key." + ) // Server error strings can contain credentials; never display them. + override fun onDisconnect() { if (!closed.get()) fail("The streaming server disconnected. Start a new stream to reconnect.") } + override fun onAuthError() = fail("The server rejected authentication. Check the stream key in your live dashboard.") + override fun onAuthSuccess() = Unit + override fun onNewBitrate(bitrate: Long) { if (!closed.get()) handler.post { if (!closed.get()) onBitrate(bitrate) } } +} diff --git a/app/src/main/java/com/screenkit/screenrecorder/ui/CaptureViewModel.kt b/app/src/main/java/com/screenkit/screenrecorder/ui/CaptureViewModel.kt index e4f32d5..be138d3 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/ui/CaptureViewModel.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/ui/CaptureViewModel.kt @@ -37,6 +37,7 @@ internal data class CaptureUiState( val screenSessionActive: Boolean = false, val screenRecording: Boolean = false, val screenPaused: Boolean = false, + val liveStreaming: Boolean = false, val screenshotInProgress: Boolean = false, val screenRecordingStartedAt: Long = 0L, val cameraSessionActive: Boolean = false, diff --git a/app/src/main/java/com/screenkit/screenrecorder/ui/LiveStreamCard.kt b/app/src/main/java/com/screenkit/screenrecorder/ui/LiveStreamCard.kt new file mode 100644 index 0000000..fc5d9bd --- /dev/null +++ b/app/src/main/java/com/screenkit/screenrecorder/ui/LiveStreamCard.kt @@ -0,0 +1,150 @@ +package com.screenkit.screenrecorder.ui + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.screenkit.screenrecorder.data.CaptureAudioMode +import com.screenkit.screenrecorder.streaming.LiveStreamConfig +import com.screenkit.screenrecorder.streaming.LiveStreamSession +import kotlinx.coroutines.delay + +@Composable +internal fun LiveStreamCard( + busy: Boolean, + audioMode: CaptureAudioMode, + onAudioMode: (CaptureAudioMode) -> Unit, + onStart: (LiveStreamConfig) -> Unit, + onStop: () -> Unit, + onMute: () -> Unit, +) { + val status by LiveStreamSession.status.collectAsStateWithLifecycle() + // Not rememberSaveable: keys must not enter saved-instance-state or disk. + var server by remember { mutableStateOf("rtmps://a.rtmps.youtube.com/live2") } + var key by remember { mutableStateOf("") } + var platform by remember { mutableStateOf("YouTube") } + var edge by remember { mutableStateOf(720) } + var fps by remember { mutableStateOf(30) } + var bitrate by remember { mutableStateOf("2500") } + var error by remember { mutableStateOf(null) } + var confirmation by remember { mutableStateOf(false) } + var starting by remember { mutableStateOf(false) } + var tick by remember { mutableStateOf(System.currentTimeMillis()) } + LaunchedEffect(status.active, starting) { + if (status.active) { + starting = false + while (true) { tick = System.currentTimeMillis(); delay(1000) } + } else if (starting) { + // Permission/consent cancellation may not start a service. Allow another attempt. + delay(1500) + starting = false + } + } + val editable = !status.active && !busy && !starting + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Go Live", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text("Your screen → YouTube, Facebook, or a custom RTMP/RTMPS destination.", style = MaterialTheme.typography.bodySmall) + Text(status.message, color = if (status.connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface) + if (status.active) { + val seconds = if (status.startedAt == 0L) 0L else ((tick - status.startedAt) / 1000).coerceAtLeast(0) + Text("%02d:%02d · %d kbps · %s".format(seconds / 60, seconds % 60, status.bitrate / 1000, if (status.muted) "Audio muted" else "Audio on")) + Text("Connected means the server accepts the stream—not that it is publicly live. Check your platform's dashboard.", style = MaterialTheme.typography.bodySmall) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onMute) { Text(if (status.muted) "Unmute audio" else "Mute audio") } + Button(onClick = onStop) { Text("End stream") } + } + } else { + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("YouTube", "Facebook", "Custom").forEach { choice -> + FilterChip(selected = platform == choice, enabled = editable, onClick = { + platform = choice + key = "" + server = when (choice) { + "YouTube" -> "rtmps://a.rtmps.youtube.com/live2" + "Facebook" -> "rtmps://live-api-s.facebook.com:443/rtmp" + else -> "" + } + }, label = { Text(choice) }) + } + } + OutlinedTextField(value = server, onValueChange = { server = it; error = null }, label = { Text("Server URL") }, + placeholder = { Text("rtmps://server.example/live") }, singleLine = true, enabled = editable, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = key, onValueChange = { key = it; error = null }, label = { Text("Stream key (not saved)") }, + visualTransformation = PasswordVisualTransformation(), singleLine = true, enabled = editable, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, autoCorrectEnabled = false), + modifier = Modifier.fillMaxWidth()) + Text("Copy both values from your live dashboard. Preset URLs are starting points; replace them if your platform provides a different URL. No account sign-in or stream keys are stored.", style = MaterialTheme.typography.bodySmall) + Text("Resolution · preserves screen aspect ratio") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(480, 720, 1080).forEach { value -> + FilterChip(selected = edge == value, enabled = editable, onClick = { + edge = value; bitrate = when(value) { 480 -> "1200"; 720 -> "2500"; else -> "4500" } + }, label = { Text("${value}p") }) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(24, 30, 60).forEach { value -> + FilterChip(selected = fps == value, enabled = editable, onClick = { fps = value }, label = { Text("$value fps") }) + } + } + OutlinedTextField(value = bitrate, onValueChange = { bitrate = it.filter(Char::isDigit).take(5) }, + enabled = editable, label = { Text("Video bitrate · 500–12000 kbps") }, singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth()) + Text("Sound · choose before starting") + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CaptureAudioMode.entries.forEach { choice -> + FilterChip(selected = audioMode == choice, enabled = editable, onClick = { onAudioMode(choice) }, label = { Text(choice.label) }) + } + } + if (server.trim().startsWith("rtmp://")) Text("RTMP is unencrypted. Prefer RTMPS to protect your stream and key in transit.", color = MaterialTheme.colorScheme.error) + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Button(enabled = editable, onClick = { + val config = LiveStreamConfig(server, key, edge, fps, bitrate.toIntOrNull() ?: 0) + val failure = runCatching { config.validate() }.exceptionOrNull() + if (failure != null) error = failure.message else confirmation = true + }, modifier = Modifier.fillMaxWidth()) { Text(if (busy) "Finish the active capture first" else "Go Live") } + Text("Screen only • No local MP4 • Device audio requires Android 10+ and app permission to capture playback • Protected content cannot be streamed.", style = MaterialTheme.typography.bodySmall) + } + } + } + if (confirmation) AlertDialog( + onDismissRequest = { confirmation = false }, + title = { Text("Share your screen live?") }, + text = { Text("Everything in the screen or app you choose—including visible notifications, overlays, and personal information—may be broadcast. Enable Do Not Disturb first.\n\nYour sound setting is ${audioMode.label}. You can mute audio or end the stream from the floating controls or notification. Some platforms require a final Go Live confirmation in their dashboard.") }, + confirmButton = { TextButton(onClick = { + confirmation = false + starting = true + val config = LiveStreamConfig(server, key, edge, fps, bitrate.toIntOrNull() ?: 2500) + key = "" + onStart(config) + }) { Text("Continue to screen sharing") } }, + dismissButton = { TextButton(onClick = { confirmation = false }) { Text("Cancel") } }, + ) +} diff --git a/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt b/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt index 4b782f1..9bfd0e4 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt @@ -116,6 +116,9 @@ internal fun MainScreen( onTakeScreenshot: () -> Unit, onTakePartialScreenshot: () -> Unit, onTakeLongScreenshot: () -> Unit = {}, + onStartLive: (com.screenkit.screenrecorder.streaming.LiveStreamConfig) -> Unit = {}, + onStopLive: () -> Unit = {}, + onMuteLive: () -> Unit = {}, onToggleScreenRecording: () -> Unit, onPauseResumeScreenRecording: () -> Unit = {}, onTogglePartialScreenRecording: () -> Unit, @@ -187,6 +190,12 @@ internal fun MainScreen( onToggleCameraRecording = onToggleCameraRecording, onProjectionScopeSelected = onProjectionScopeSelected, ) + LiveStreamCard( + busy = state.screenSessionActive || state.cameraRecording, + audioMode = state.audioMode, + onAudioMode = onAudioModeSelected, + onStart = onStartLive, onStop = onStopLive, onMute = onMuteLive, + ) AutoClickScrollCard( state = state, onOpenAccessibilitySettings = onOpenAccessibilitySettings, @@ -464,6 +473,7 @@ private fun CaptureActions( Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { ActionButton( label = when { + state.liveStreaming -> "End live stream" state.screenRecording -> "Stop & save" state.screenSessionActive -> "Busy…" else -> "Record whole screen" @@ -474,7 +484,7 @@ private fun CaptureActions( onClick = onToggleScreenRecording, modifier = Modifier.weight(1f), ) - if (state.screenRecording) { + if (state.screenRecording && !state.liveStreaming) { OutlinedButton( onClick = onPauseResumeScreenRecording, shape = RoundedCornerShape(14.dp), diff --git a/app/src/test/java/com/screenkit/screenrecorder/streaming/LiveStreamConfigTest.kt b/app/src/test/java/com/screenkit/screenrecorder/streaming/LiveStreamConfigTest.kt new file mode 100644 index 0000000..2966eb8 --- /dev/null +++ b/app/src/test/java/com/screenkit/screenrecorder/streaming/LiveStreamConfigTest.kt @@ -0,0 +1,48 @@ +package com.screenkit.screenrecorder.streaming + +import org.junit.Assert.* +import org.junit.Test + +class LiveStreamConfigTest { + @Test fun joinsServerAndKeyWithoutLosingQueryTokens() { + assertEquals("rtmps://host.example:443/rtmp/key?token=abc&x=1", LiveStreamConfig.validateEndpoint(" rtmps://host.example:443/rtmp/ ", "key?token=abc&x=1")) + } + @Test fun supportsRtmpAndIpv6() { + assertEquals("rtmp://localhost/live/key", LiveStreamConfig.validateEndpoint("rtmp://localhost/live", "key")) + assertEquals("rtmps://[::1]:443/live/key", LiveStreamConfig.validateEndpoint("rtmps://[::1]:443/live", "key")) + } + @Test fun rejectsInvalidDestinationsWithoutEchoingSecrets() { + val secret = "private-key" + listOf("https://host/live", "rtmp:///live", "rtmp://host", "rtmp://host/", "rtmp://user:pass@host/live", + "rtmp://host:0/live", "rtmp://host:65536/live", "rtmps://host/live?key=secret", "rtmps://host/live#fragment").forEach { server -> + val failure = runCatching { LiveStreamConfig.validateEndpoint(server, secret) }.exceptionOrNull() + assertNotNull(server, failure) + assertFalse(failure!!.message.orEmpty().contains(secret)) + } + listOf("", "a b", "a\nb", "rtmp://host/key", "key#fragment", "/key", "key%bad%", "a".repeat(4097)).forEach { key -> + assertTrue(runCatching { LiveStreamConfig.validateEndpoint("rtmps://host/live", key) }.isFailure) + } + } + @Test fun validatesQualityBounds() { + LiveStreamConfig("rtmps://host/live", "key").validate() + assertTrue(runCatching { LiveStreamConfig("rtmps://host/live", "key", 1440).validate() }.isFailure) + assertTrue(runCatching { LiveStreamConfig("rtmps://host/live", "key", fps = 120).validate() }.isFailure) + assertTrue(runCatching { LiveStreamConfig("rtmps://host/live", "key", bitrateKbps = 499).validate() }.isFailure) + assertTrue(runCatching { LiveStreamConfig("rtmps://host/live", "key", bitrateKbps = 12001).validate() }.isFailure) + } + @Test fun preservesAspectRatioAndEvenDimensionsWithoutUpscaling() { + val config = LiveStreamConfig("rtmps://host/live", "key") + assertEquals(720 to 1280, config.dimensions(1080, 1920)) + assertEquals(1280 to 720, config.dimensions(1920, 1080)) + assertEquals(480 to 800, config.dimensions(480, 800)) + val (w, h) = config.dimensions(1081, 2401) + assertEquals(0, w % 2) + assertEquals(0, h % 2) + assertTrue(h <= 1920) + } + @Test fun capsExtremeAspectRatioAndDoesNotLeakKeyInToString() { + val config = LiveStreamConfig("rtmps://host/live", "private-key", 1080) + assertEquals(864 to 1920, config.dimensions(1080, 2400)) + assertFalse(config.toString().contains("private-key")) + } +} diff --git a/docs/live-stream-testing.md b/docs/live-stream-testing.md new file mode 100644 index 0000000..6cf615c --- /dev/null +++ b/docs/live-stream-testing.md @@ -0,0 +1,33 @@ +# Go Live validation checklist + +## Automated + +`gradle :app:testDebugUnitTest :app:assembleDebug` runs endpoint/quality/dimension tests +and compiles the APK. Tests must pass before the APK publish step in CI. + +## Device / private-server tests (manual, require an Android device) + +Do not use real keys in logs or test fixtures. Prefer a local RTMP receiver or an +unlisted/private platform broadcast. Verify at the receiver, not just the sender UI. + +- Android 7–9: microphone/silent; Android 10+: device and mixed audio. +- Android 14/15: fresh consent for every session; whole-screen and single-app sharing; + refuse consent, deny microphone, deny notifications, rotate while consent is visible. +- RTMP test receiver and RTMPS platform receiver: picture, audible audio, A/V sync + at startup and after 10 minutes; 480p/720p/1080p and 24/30/60 fps where supported. +- Sound off still produces a silent AAC track. Mute/unmute both sources while connected; + inspect receiver latency and verify no fresh audio after queued data drains. +- Invalid key, unreachable host, TLS failure, connection timeout, network loss, slow uplink, + server disconnect: clear sanitized error, no saved phantom MP4, no running projection, + microphone indicator, or orphan notification; starting a new stream succeeds. +- Stop while connecting and after publishing via app, floating controls, notification, + Android sharing chip, shake and volume-down. Verify resources release and no reconnect. +- Open app while streaming, background app, dismiss bubble, lock/unlock, rotate, kill process. + Status remains correct while process is alive; process death never automatically restarts. +- Try screenshot, screen recording and camera recording during a stream: no second capture. +- Regression: record MP4 with sound on/off; pause/resume; save screenshot; camera recording. +- Verify screen-sharing privacy notice, masked key, no key in preferences/saved state or + ScreenKit UI errors. Inspect logcat for any third-party transport leakage before release use. +- Check dashboard preview and public Go Live separately. Encoder success is not public-live status. + +Automated tests do not substitute for these Android/network/platform checks. diff --git a/settings.gradle.kts b/settings.gradle.kts index 6eb0117..fedec4e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,6 +11,9 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven("https://jitpack.io") { + content { includeGroup("com.github.pedroSG94.RootEncoder") } + } } } From 9c0f460299c0487fc1f47a5598b20819bf075963 Mon Sep 17 00:00:00 2001 From: sharthak18 <157966738+sharthak18@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:20:42 +0000 Subject: [PATCH 02/10] Harden live transport TLS, privacy and AVC packetization with regression tests Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/android-apk.yml | 2 +- README.md | 13 +- app/build.gradle.kts | 2 +- .../services/ScreenKitAutoService.kt | 7 +- .../services/ScreenRecorderEngine.kt | 2 +- .../services/ShakeAndKeyStopDetector.kt | 2 +- .../screenrecorder/streaming/RtmpPublisher.kt | 11 +- .../screenkit/screenrecorder/ui/MainScreen.kt | 5 +- build.gradle.kts | 1 + settings.gradle.kts | 7 +- third_party/rootencoder/LICENSE.txt | 201 ++++++ third_party/rootencoder/README.md | 36 + .../rootencoder/common/build.gradle.kts | 23 + .../common/src/main/AndroidManifest.xml | 1 + .../main/java/com/pedro/common/AudioCodec.kt | 21 + .../main/java/com/pedro/common/BitBuffer.kt | 41 ++ .../java/com/pedro/common/BitrateChecker.java | 24 + .../java/com/pedro/common/BitrateManager.kt | 50 ++ .../java/com/pedro/common/ConnectChecker.kt | 29 + .../com/pedro/common/ConnectCheckerEvent.java | 62 ++ .../java/com/pedro/common/ConnectionFailed.kt | 51 ++ .../main/java/com/pedro/common/Extensions.kt | 156 +++++ .../main/java/com/pedro/common/StreamEvent.kt | 25 + .../main/java/com/pedro/common/StreamLog.kt | 8 + .../java/com/pedro/common/TLSSocketFactory.kt | 89 +++ .../main/java/com/pedro/common/TimeUtils.kt | 32 + .../main/java/com/pedro/common/UrlParser.kt | 108 +++ .../main/java/com/pedro/common/VideoCodec.kt | 21 + .../java/com/pedro/common/av1/Av1Parser.kt | 104 +++ .../src/main/java/com/pedro/common/av1/Obu.kt | 30 + .../main/java/com/pedro/common/av1/ObuType.kt | 34 + .../java/com/pedro/common/base/BaseSender.kt | 148 +++++ .../java/com/pedro/common/frame/MediaFrame.kt | 20 + .../com/pedro/common/socket/StreamSocket.kt | 29 + .../pedro/common/socket/TcpStreamSocket.kt | 116 ++++ .../pedro/common/socket/TcpStreamSocketImp.kt | 80 +++ .../pedro/common/socket/UdpStreamSocket.kt | 88 +++ .../java/com/pedro/common/StreamUrlTest.kt | 18 + .../java/com/pedro/common/TlsSocketTest.kt | 61 ++ .../test/resources/localhost-test-only.p12 | Bin 0 -> 2666 bytes third_party/rootencoder/rtmp/build.gradle.kts | 21 + .../rtmp/src/main/AndroidManifest.xml | 1 + .../java/com/pedro/rtmp/amf/AmfVersion.kt | 24 + .../java/com/pedro/rtmp/amf/v0/AmfBoolean.kt | 48 ++ .../java/com/pedro/rtmp/amf/v0/AmfData.kt | 79 +++ .../java/com/pedro/rtmp/amf/v0/AmfDate.kt | 59 ++ .../com/pedro/rtmp/amf/v0/AmfEcmaArray.kt | 81 +++ .../com/pedro/rtmp/amf/v0/AmfLongString.kt | 60 ++ .../java/com/pedro/rtmp/amf/v0/AmfNull.kt | 47 ++ .../java/com/pedro/rtmp/amf/v0/AmfNumber.kt | 53 ++ .../java/com/pedro/rtmp/amf/v0/AmfObject.kt | 138 ++++ .../com/pedro/rtmp/amf/v0/AmfObjectEnd.kt | 53 ++ .../com/pedro/rtmp/amf/v0/AmfStrictArray.kt | 74 +++ .../java/com/pedro/rtmp/amf/v0/AmfString.kt | 62 ++ .../java/com/pedro/rtmp/amf/v0/AmfType.kt | 36 + .../com/pedro/rtmp/amf/v0/AmfUndefined.kt | 47 ++ .../com/pedro/rtmp/amf/v0/AmfUnsupported.kt | 47 ++ .../com/pedro/rtmp/amf/v0/AmfXmlDocument.kt | 46 ++ .../java/com/pedro/rtmp/amf/v3/Amf3Array.kt | 40 ++ .../java/com/pedro/rtmp/amf/v3/Amf3Data.kt | 77 +++ .../com/pedro/rtmp/amf/v3/Amf3Dictionary.kt | 40 ++ .../java/com/pedro/rtmp/amf/v3/Amf3Double.kt | 47 ++ .../java/com/pedro/rtmp/amf/v3/Amf3False.kt | 38 ++ .../java/com/pedro/rtmp/amf/v3/Amf3Integer.kt | 40 ++ .../java/com/pedro/rtmp/amf/v3/Amf3Null.kt | 38 ++ .../java/com/pedro/rtmp/amf/v3/Amf3Object.kt | 91 +++ .../java/com/pedro/rtmp/amf/v3/Amf3String.kt | 40 ++ .../java/com/pedro/rtmp/amf/v3/Amf3True.kt | 38 ++ .../java/com/pedro/rtmp/amf/v3/Amf3Type.kt | 27 + .../com/pedro/rtmp/amf/v3/Amf3Undefined.kt | 38 ++ .../java/com/pedro/rtmp/flv/BasePacket.kt | 28 + .../main/java/com/pedro/rtmp/flv/FlvPacket.kt | 27 + .../main/java/com/pedro/rtmp/flv/FlvType.kt | 24 + .../com/pedro/rtmp/flv/audio/AudioFormat.kt | 27 + .../pedro/rtmp/flv/audio/AudioObjectType.kt | 25 + .../com/pedro/rtmp/flv/audio/AudioSize.kt | 24 + .../pedro/rtmp/flv/audio/AudioSoundRate.kt | 25 + .../pedro/rtmp/flv/audio/AudioSoundType.kt | 24 + .../flv/audio/config/AudioSpecificConfig.kt | 73 +++ .../pedro/rtmp/flv/audio/packet/AacPacket.kt | 95 +++ .../pedro/rtmp/flv/audio/packet/G711Packet.kt | 62 ++ .../pedro/rtmp/flv/video/FourCCPacketType.kt | 30 + .../com/pedro/rtmp/flv/video/VideoDataType.kt | 24 + .../com/pedro/rtmp/flv/video/VideoFormat.kt | 29 + .../com/pedro/rtmp/flv/video/VideoNalType.kt | 28 + .../rtmp/flv/video/config/SPSH265Parser.kt | 120 ++++ .../video/config/VideoSpecificConfigAV1.kt | 274 ++++++++ .../video/config/VideoSpecificConfigAVC.kt | 70 ++ .../video/config/VideoSpecificConfigHEVC.kt | 145 +++++ .../pedro/rtmp/flv/video/packet/Av1Packet.kt | 112 ++++ .../rtmp/flv/video/packet/AvcAccessUnit.kt | 38 ++ .../pedro/rtmp/flv/video/packet/H264Packet.kt | 138 ++++ .../pedro/rtmp/flv/video/packet/H265Packet.kt | 194 ++++++ .../com/pedro/rtmp/rtmp/CommandsManager.kt | 238 +++++++ .../pedro/rtmp/rtmp/CommandsManagerAmf0.kt | 162 +++++ .../pedro/rtmp/rtmp/CommandsManagerAmf3.kt | 153 +++++ .../java/com/pedro/rtmp/rtmp/Handshake.kt | 167 +++++ .../java/com/pedro/rtmp/rtmp/RtmpClient.kt | 614 ++++++++++++++++++ .../java/com/pedro/rtmp/rtmp/RtmpSender.kt | 129 ++++ .../pedro/rtmp/rtmp/chunk/ChunkStreamId.kt | 29 + .../com/pedro/rtmp/rtmp/chunk/ChunkType.kt | 82 +++ .../java/com/pedro/rtmp/rtmp/message/Abort.kt | 49 ++ .../rtmp/rtmp/message/Acknowledgement.kt | 49 ++ .../com/pedro/rtmp/rtmp/message/Aggregate.kt | 41 ++ .../java/com/pedro/rtmp/rtmp/message/Audio.kt | 47 ++ .../pedro/rtmp/rtmp/message/BasicHeader.kt | 96 +++ .../pedro/rtmp/rtmp/message/MessageType.kt | 134 ++++ .../com/pedro/rtmp/rtmp/message/RtmpHeader.kt | 165 +++++ .../pedro/rtmp/rtmp/message/RtmpMessage.kt | 146 +++++ .../pedro/rtmp/rtmp/message/SetChunkSize.kt | 50 ++ .../rtmp/rtmp/message/SetPeerBandwidth.kt | 57 ++ .../java/com/pedro/rtmp/rtmp/message/Video.kt | 47 ++ .../rtmp/message/WindowAcknowledgementSize.kt | 53 ++ .../rtmp/rtmp/message/command/Command.kt | 45 ++ .../rtmp/rtmp/message/command/CommandAmf0.kt | 99 +++ .../rtmp/rtmp/message/command/CommandAmf3.kt | 99 +++ .../pedro/rtmp/rtmp/message/control/Event.kt | 22 + .../pedro/rtmp/rtmp/message/control/Type.kt | 135 ++++ .../rtmp/rtmp/message/control/UserControl.kt | 72 ++ .../com/pedro/rtmp/rtmp/message/data/Data.kt | 36 + .../pedro/rtmp/rtmp/message/data/DataAmf0.kt | 82 +++ .../pedro/rtmp/rtmp/message/data/DataAmf3.kt | 81 +++ .../rtmp/rtmp/message/shared/SharedObject.kt | 40 ++ .../rtmp/message/shared/SharedObjectAmf0.kt | 26 + .../rtmp/message/shared/SharedObjectAmf3.kt | 26 + .../java/com/pedro/rtmp/utils/AuthUtil.kt | 102 +++ .../java/com/pedro/rtmp/utils/BitBuffer.kt | 106 +++ .../pedro/rtmp/utils/CommandSessionHistory.kt | 53 ++ .../java/com/pedro/rtmp/utils/RtmpConfig.kt | 26 + .../main/java/com/pedro/rtmp/utils/Utils.kt | 132 ++++ .../com/pedro/rtmp/utils/socket/RtmpSocket.kt | 48 ++ .../com/pedro/rtmp/utils/socket/TcpSocket.kt | 88 +++ .../rtmp/utils/socket/TcpTunneledSocket.kt | 233 +++++++ .../flv/video/packet/AvcAccessUnitTest.kt | 40 ++ 134 files changed, 9006 insertions(+), 17 deletions(-) create mode 100644 third_party/rootencoder/LICENSE.txt create mode 100644 third_party/rootencoder/README.md create mode 100644 third_party/rootencoder/common/build.gradle.kts create mode 100644 third_party/rootencoder/common/src/main/AndroidManifest.xml create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/AudioCodec.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/BitBuffer.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/BitrateChecker.java create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/BitrateManager.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/ConnectChecker.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/ConnectCheckerEvent.java create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/ConnectionFailed.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/Extensions.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/StreamEvent.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/StreamLog.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/TLSSocketFactory.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/TimeUtils.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/UrlParser.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/VideoCodec.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/av1/Av1Parser.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/av1/Obu.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/av1/ObuType.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/base/BaseSender.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/frame/MediaFrame.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/socket/StreamSocket.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/socket/TcpStreamSocket.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/socket/TcpStreamSocketImp.kt create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/socket/UdpStreamSocket.kt create mode 100644 third_party/rootencoder/common/src/test/java/com/pedro/common/StreamUrlTest.kt create mode 100644 third_party/rootencoder/common/src/test/java/com/pedro/common/TlsSocketTest.kt create mode 100644 third_party/rootencoder/common/src/test/resources/localhost-test-only.p12 create mode 100644 third_party/rootencoder/rtmp/build.gradle.kts create mode 100644 third_party/rootencoder/rtmp/src/main/AndroidManifest.xml create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/AmfVersion.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfBoolean.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfData.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfDate.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfEcmaArray.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfLongString.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfNull.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfNumber.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfObject.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfObjectEnd.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfStrictArray.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfString.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfType.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfUndefined.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfUnsupported.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfXmlDocument.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Array.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Data.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Dictionary.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Double.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3False.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Integer.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Null.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Object.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3String.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3True.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Type.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Undefined.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/BasePacket.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/FlvPacket.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/FlvType.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioFormat.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioObjectType.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioSize.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioSoundRate.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioSoundType.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/config/AudioSpecificConfig.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/packet/AacPacket.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/packet/G711Packet.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/FourCCPacketType.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/VideoDataType.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/VideoFormat.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/VideoNalType.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/SPSH265Parser.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/VideoSpecificConfigAV1.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/VideoSpecificConfigAVC.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/VideoSpecificConfigHEVC.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/Av1Packet.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/AvcAccessUnit.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/H264Packet.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/H265Packet.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManager.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf0.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf3.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/Handshake.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/RtmpClient.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/RtmpSender.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/chunk/ChunkStreamId.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/chunk/ChunkType.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Abort.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Acknowledgement.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Aggregate.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Audio.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/BasicHeader.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/MessageType.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/RtmpHeader.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/RtmpMessage.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/SetChunkSize.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/SetPeerBandwidth.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Video.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/WindowAcknowledgementSize.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/command/Command.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/command/CommandAmf0.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/command/CommandAmf3.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/control/Event.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/control/Type.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/control/UserControl.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/data/Data.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/data/DataAmf0.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/data/DataAmf3.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/shared/SharedObject.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/shared/SharedObjectAmf0.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/shared/SharedObjectAmf3.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/AuthUtil.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/BitBuffer.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/CommandSessionHistory.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/RtmpConfig.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/Utils.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/socket/RtmpSocket.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/socket/TcpSocket.kt create mode 100644 third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/socket/TcpTunneledSocket.kt create mode 100644 third_party/rootencoder/rtmp/src/test/java/com/pedro/rtmp/flv/video/packet/AvcAccessUnitTest.kt diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 994bf85..9f431a7 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -42,7 +42,7 @@ jobs: id: build run: | set -o pipefail - gradle --no-daemon :app:testDebugUnitTest :app:assembleDebug 2>&1 | tee /tmp/gradle-build.log + gradle --no-daemon :stream-common:testDebugUnitTest :stream-rtmp:testDebugUnitTest :app:testDebugUnitTest :app:assembleDebug 2>&1 | tee /tmp/gradle-build.log - name: Post Gradle diagnostics to the temporary issue on failure if: failure() && steps.build.outcome == 'failure' diff --git a/README.md b/README.md index 180b116..404eb1d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A native, high-performance Android capture, creator studio & screen automation s [⬇️ **Download the latest ScreenKit v2.1.0 APK**](https://github.com/sharthak18/ScreenKit/releases/latest/download/ScreenKit.apk) -- **Current Version**: `v2.1.0` (`versionCode = 7`) +- **Current Version**: `v2.1.0` (`versionCode = 8`) - Compatible with **Android 7.0 (API 24) to Android 15 (API 35)**. - Device Audio & Mixed (`Mic + Device`) playback capture supported on **Android 10+**. - Full screen scope and single-app exclusion selection supported on **Android 14+**. @@ -16,7 +16,13 @@ A native, high-performance Android capture, creator studio & screen automation s --- -## What's New in v2.1.0 (13 Major Features) +## What's New in v2.1.0 + +**Go Live** adds RTMP/RTMPS screen streaming with quality settings, microphone/device/mixed audio, +live timer and outgoing bitrate, mute/end controls, and private in-memory stream keys. +See [Go Live setup](#go-live--v210) below. + +## Included from v2.0.0 (13 Major Features) 1. **Touch Macro Recorder ("Record My Actions & Replay")**: Tap `● Macro` in the Auto tool, press `● Rec`, perform any real-time sequence of finger taps and swipes across the screen, tap `■ Done`, and replay your exact touch choreography (`1×` to `Infinite ∞`). 2. **Saved Auto-Clicker Profiles & Presets (Slots 1–3)**: Save and load named pin positions, delays, speeds, and modes into 3 instant profile slots (pre-loaded with **TikTok/Reels Scroll**, **Game Farming 1→4**, and **Reader Slow Scroll**). @@ -188,5 +194,6 @@ that your account has live-streaming access. compatibility. Test with a private/unlisted broadcast before going public. Transport uses the Apache-2.0 licensed [RootEncoder RTMP module, version 2.5.3](https://github.com/pedroSG94/RootEncoder/tree/2.5.3/rtmp), -pinned for compatibility with this project's Kotlin toolchain. ScreenKit reuses its own +vendored as source for reproducible builds, with TLS hostname verification, credential-safe logging, +and disconnect handling patches documented in `third_party/rootencoder/README.md`. ScreenKit reuses its own MediaCodec screen encoder and mixed-audio capture pipeline; streaming does not write a temporary MP4. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 124b61b..2852b15 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -32,7 +32,7 @@ android { dependencies { // Transport only; reuse ScreenKit's H.264/AAC screen and mixed-audio encoders. - implementation("com.github.pedroSG94.RootEncoder:rtmp:2.5.3") + implementation(project(":stream-rtmp")) testImplementation("junit:junit:4.13.2") implementation(platform("androidx.compose:compose-bom:2024.12.01")) implementation("androidx.activity:activity-compose:1.9.3") diff --git a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenKitAutoService.kt b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenKitAutoService.kt index d3ad1cd..9189f63 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenKitAutoService.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenKitAutoService.kt @@ -42,11 +42,12 @@ class ScreenKitAutoService : AccessibilityService() { if (event != null && event.action == KeyEvent.ACTION_DOWN && event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN && - AutoClickScrollOverlay.isExecuting && + (AutoClickScrollOverlay.isExecuting || ScreenCaptureService.isLive) && SettingsStore.volumeKeyStopEnabled(this) ) { - AutoClickScrollOverlay.stopExecution() - CaptureIntents.toast(this, "Volume Down pressed: Auto Click / Scroll stopped.") + if (AutoClickScrollOverlay.isExecuting) AutoClickScrollOverlay.stopExecution() + if (ScreenCaptureService.isLive) CaptureIntents.stopScreenCapture(this) + CaptureIntents.toast(this, "Volume Down pressed: active live stream / automation stopped.") return true } return super.onKeyEvent(event) diff --git a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenRecorderEngine.kt b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenRecorderEngine.kt index 20d19b4..71534ad 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenRecorderEngine.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenRecorderEngine.kt @@ -640,7 +640,7 @@ internal class ScreenRecorderEngine( private fun registerTrack(codec: MediaCodec, isVideo: Boolean) { val format = runCatching { codec.outputFormat }.getOrNull() ?: return if (publisher != null) { - if (isVideo) runCatching { publisher.videoFormat(format) }.onFailure { publisher.encoderError() } + if (isVideo) runCatching { publisher?.videoFormat(format) }.onFailure { publisher?.encoderError() } return } synchronized(muxerLock) { diff --git a/app/src/main/java/com/screenkit/screenrecorder/services/ShakeAndKeyStopDetector.kt b/app/src/main/java/com/screenkit/screenrecorder/services/ShakeAndKeyStopDetector.kt index 3f9a9d9..73f6786 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/services/ShakeAndKeyStopDetector.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/services/ShakeAndKeyStopDetector.kt @@ -67,7 +67,7 @@ internal object ShakeAndKeyStopDetector : SensorEventListener { } if (screenRecRunning) { CaptureIntents.stopScreenCapture(ctx) - CaptureIntents.toast(ctx, "Shake detected: Saving screen recording…") + CaptureIntents.toast(ctx, if (ScreenCaptureService.isLive) "Shake detected: ending live stream…" else "Shake detected: Saving screen recording…") } } else { firstPeakAtMs = now diff --git a/app/src/main/java/com/screenkit/screenrecorder/streaming/RtmpPublisher.kt b/app/src/main/java/com/screenkit/screenrecorder/streaming/RtmpPublisher.kt index 43bed23..096a0f7 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/streaming/RtmpPublisher.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/streaming/RtmpPublisher.kt @@ -19,9 +19,11 @@ internal class RtmpPublisher( private val closed = AtomicBoolean(false) @Volatile private var connected = false @Volatile private var waitingForKeyFrame = true + private var timestampOriginUs = 0L private val client = RtmpClient(this).apply { setLogs(false) - setCheckServerAlive(true) + // Many streaming servers block ICMP/echo probes; use socket/read/write failures instead. + setCheckServerAlive(false) resizeCache(120) setAudioInfo(48_000, false) } @@ -41,14 +43,19 @@ internal class RtmpPublisher( client.setVideoInfo(sps.duplicate(), pps.duplicate(), null) } + @Synchronized fun sample(video: Boolean, buffer: ByteBuffer, info: MediaCodec.BufferInfo) { if (closed.get() || !connected) return if (waitingForKeyFrame) { if (!video || info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME == 0) return + timestampOriginUs = info.presentationTimeUs waitingForKeyFrame = false } // RootEncoder clones the bytes synchronously before queueing them; the codec may then release its buffer. - if (video) client.sendVideo(buffer, info) else client.sendAudio(buffer, info) + val pts = info.presentationTimeUs - timestampOriginUs + if (pts < 0L) return + val adjusted = MediaCodec.BufferInfo().apply { set(info.offset, info.size, pts, info.flags) } + if (video) client.sendVideo(buffer, adjusted) else client.sendAudio(buffer, adjusted) } fun encoderError() = fail("The audio/video encoder stopped. Lower the quality and start a new stream.") diff --git a/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt b/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt index 9bfd0e4..7794b06 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt @@ -458,9 +458,10 @@ private fun CaptureActions( Row(verticalAlignment = Alignment.CenterVertically) { ActionIcon(Icons.Filled.FiberManualRecord, Color(0xFFFF6671), "rec") Column(Modifier.padding(start = 12.dp).weight(1f)) { - Text("Screen recording", color = SoftWhite, fontSize = 16.sp, fontWeight = FontWeight.Bold) + Text(if (state.liveStreaming) "Live screen sharing" else "Screen recording", color = SoftWhite, fontSize = 16.sp, fontWeight = FontWeight.Bold) Text( - if (state.screenPaused) "PAUSED · Tap Resume to continue recording" + if (state.liveStreaming) "Broadcasting screen · No local MP4" + else if (state.screenPaused) "PAUSED · Tap Resume to continue recording" else "H.264 MP4 · Full or partial area · Pause/Resume supported", color = if (state.screenPaused) Color(0xFFFFC857) else Muted, fontSize = 11.sp, diff --git a/build.gradle.kts b/build.gradle.kts index 1b4481a..8786d0e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + id("com.android.library") version "8.7.3" apply false id("com.android.application") version "8.7.3" apply false id("org.jetbrains.kotlin.android") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false diff --git a/settings.gradle.kts b/settings.gradle.kts index fedec4e..deeaea9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,11 +11,12 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven("https://jitpack.io") { - content { includeGroup("com.github.pedroSG94.RootEncoder") } - } } } rootProject.name = "ScreenKit" include(":app") + +include(":stream-common", ":stream-rtmp") +project(":stream-common").projectDir = file("third_party/rootencoder/common") +project(":stream-rtmp").projectDir = file("third_party/rootencoder/rtmp") diff --git a/third_party/rootencoder/LICENSE.txt b/third_party/rootencoder/LICENSE.txt new file mode 100644 index 0000000..8dada3e --- /dev/null +++ b/third_party/rootencoder/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/rootencoder/README.md b/third_party/rootencoder/README.md new file mode 100644 index 0000000..08ee1c0 --- /dev/null +++ b/third_party/rootencoder/README.md @@ -0,0 +1,36 @@ +# RootEncoder transport subset + +Upstream: https://github.com/pedroSG94/RootEncoder +Tag: **2.5.3**, commit `d0257886099c4e8165e5d10252d4d2760f92e6dd` +License: Apache-2.0; see LICENSE.txt and individual source headers. + +Only `common/src/main` and `rtmp/src/main` are included (not the encoder, camera, +RTSP, SRT, UDP, UI, or sample applications). This avoids a runtime/download dependency +on JitPack and lets security fixes be reviewed and built from pinned source. +Gradle files and manifests are adapted to ScreenKit's toolchain and minSdk 24. + +## ScreenKit changes + +- All upstream Android Log calls route to a no-op StreamLog. `setLogs(false)` alone + only suppresses packet logs; command logs include private stream names/keys. +- `TcpStreamSocketImp` sets TLS `serverName`. Ktor 3.0.0 uses this for SNI **and + certificate hostname verification**; without it only the certificate chain is checked. + Platform/system trust remains enabled; no trust-all manager is installed. +- Own the raw TCP socket before TLS, ensuring failed/cancelled handshakes can close it. + A null socket is no longer reported as connected. +- `RtmpClient` reports read-side server failures to its connection callback, so the + capture service shuts down instead of showing a stale LIVE status. +- `UrlParser` preserves raw path/query encoding, preventing percent-escaped stream + tokens from being decoded or duplicated. Transport supports one app/stream endpoint. + +- H.264 packetization splits multi-NAL access units into individual AVCC lengths, + handling SEI, multiple slices, repeated parameter sets, and three/four-byte start codes. + Pure-JVM tests cover packet structure and percent-escaped URL tokens. + +Keep these patches when upgrading. Changes to transport must preserve the license +and be checked with both a private RTMP receiver and RTMPS platform endpoints. + +`common/src/test/resources/localhost-test-only.p12` contains a synthetic self-signed +localhost certificate and test-only private key (password `test-only`), used exclusively +by JVM TLS tests. It is never bundled in the app or installed in a production trust store. +TLS tests cover trusted/matching, trusted/wrong-host, and untrusted-server connections. diff --git a/third_party/rootencoder/common/build.gradle.kts b/third_party/rootencoder/common/build.gradle.kts new file mode 100644 index 0000000..c77c0a9 --- /dev/null +++ b/third_party/rootencoder/common/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.pedro.common" + compileSdk = 35 + defaultConfig { minSdk = 24 } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + testImplementation("junit:junit:4.13.2") + implementation("io.ktor:ktor-network:3.0.0") + implementation("io.ktor:ktor-network-tls:3.0.0") + implementation("androidx.annotation:annotation:1.9.0") +} diff --git a/third_party/rootencoder/common/src/main/AndroidManifest.xml b/third_party/rootencoder/common/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc947c5 --- /dev/null +++ b/third_party/rootencoder/common/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/AudioCodec.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/AudioCodec.kt new file mode 100644 index 0000000..4fb5d51 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/AudioCodec.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.common + +enum class AudioCodec { + G711, AAC, OPUS +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/BitBuffer.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/BitBuffer.kt new file mode 100644 index 0000000..3cf52b8 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/BitBuffer.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.common + +/** + * Created by pedro on 9/12/23. + * + */ +class BitBuffer(private val buffer: ByteArray) { + + fun getBits(offset: Int, size: Int): Int { + val startIndex = offset / 8 + val startBit = offset % 8 + var result = 0 + var bitNum = startBit + + for (i in 0 until size) { + val nextByte = (startBit + i) % 8 < startBit + val currentIndex = startIndex + if (nextByte) 1 else 0 + val currentBit = (startBit + i) % 8 + val bitValue = (buffer[currentIndex].toInt() ushr 7 - currentBit) and 0x01 + result = (result shl 1) or bitValue + bitNum++ + } + return result + } +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/BitrateChecker.java b/third_party/rootencoder/common/src/main/java/com/pedro/common/BitrateChecker.java new file mode 100644 index 0000000..a330bc6 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/BitrateChecker.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.common; + +/** + * Created by pedro on 30/5/24. + */ +public interface BitrateChecker { + default void onNewBitrate(long bitrate) {} +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/BitrateManager.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/BitrateManager.kt new file mode 100644 index 0000000..a26f410 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/BitrateManager.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.common + + +/** + * Created by pedro on 8/04/21. + * Improved by perotom on 7/05/24. + * + * Calculate video and audio bitrate per second based on an exponential moving average. + */ +open class BitrateManager(private val bitrateChecker: BitrateChecker) { + + private var bitrate = 0L + private var bitrateOld = 0L + var exponentialFactor: Float = 1f + private var timeStamp = TimeUtils.getCurrentTimeMillis() + + suspend fun calculateBitrate(size: Long) { + bitrate += size + val timeDiff = TimeUtils.getCurrentTimeMillis() - timeStamp + if (timeDiff >= 1000) { + val currentValue = (bitrate / (timeDiff / 1000f)).toLong() + if (bitrateOld == 0L) { bitrateOld = currentValue } + bitrateOld = (bitrateOld + exponentialFactor * (currentValue - bitrateOld)).toLong() + onMainThread { bitrateChecker.onNewBitrate(bitrateOld) } + timeStamp = TimeUtils.getCurrentTimeMillis() + bitrate = 0 + } + } + + fun reset() { + bitrate = 0 + bitrateOld = 0 + } +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/ConnectChecker.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/ConnectChecker.kt new file mode 100644 index 0000000..c3391cc --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/ConnectChecker.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.common + +/** + * Created by pedro on 21/08/23. + */ +interface ConnectChecker: BitrateChecker { + fun onConnectionStarted(url: String) + fun onConnectionSuccess() + fun onConnectionFailed(reason: String) + fun onDisconnect() + fun onAuthError() + fun onAuthSuccess() +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/ConnectCheckerEvent.java b/third_party/rootencoder/common/src/main/java/com/pedro/common/ConnectCheckerEvent.java new file mode 100644 index 0000000..b73c178 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/ConnectCheckerEvent.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.common; + +import org.jetbrains.annotations.NotNull; + +/** + * Created by pedro on 8/3/24. + */ +public interface ConnectCheckerEvent extends ConnectChecker { + + void onStreamEvent(StreamEvent event, String message); + + @Override + default void onConnectionStarted(@NotNull String url) { + onStreamEvent(StreamEvent.STARTED, url); + } + + @Override + default void onConnectionSuccess() { + onStreamEvent(StreamEvent.CONNECTED, ""); + } + + @Override + default void onConnectionFailed(@NotNull String reason) { + onStreamEvent(StreamEvent.FAILED, reason); + } + + @Override + default void onNewBitrate(long bitrate) { + onStreamEvent(StreamEvent.NEW_BITRATE, Long.toString(bitrate)); + } + + @Override + default void onDisconnect() { + onStreamEvent(StreamEvent.DISCONNECTED, ""); + } + + @Override + default void onAuthError() { + onStreamEvent(StreamEvent.AUTH_ERROR, ""); + } + + @Override + default void onAuthSuccess() { + onStreamEvent(StreamEvent.AUTH_SUCCESS, ""); + } +} \ No newline at end of file diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/ConnectionFailed.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/ConnectionFailed.kt new file mode 100644 index 0000000..7635705 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/ConnectionFailed.kt @@ -0,0 +1,51 @@ +/* + * + * * Copyright (C) 2024 pedroSG94. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package com.pedro.common + +/** + * Created by pedro on 23/9/24. + */ +enum class ConnectionFailed { + ENDPOINT_MALFORMED, TIMEOUT, REFUSED, CLOSED_BY_SERVER, NO_INTERNET, UNKNOWN; + + companion object { + fun parse(reason: String): ConnectionFailed { + return if ( + reason.contains("network is unreachable", ignoreCase = true) || + reason.contains("software caused connection abort", ignoreCase = true) || + reason.contains("no route to host", ignoreCase = true) + ) { + NO_INTERNET + } else if (reason.contains("broken pipe", ignoreCase = true)) { + CLOSED_BY_SERVER + } else if (reason.contains("connection refused", ignoreCase = true)) { + REFUSED + } else if (reason.contains("endpoint malformed", ignoreCase = true)) { + ENDPOINT_MALFORMED + } else if ( + reason.contains("timeout", ignoreCase = true) || + reason.contains("timed out", ignoreCase = true) + ) { + TIMEOUT + } else { + UNKNOWN + } + } + } +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/Extensions.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/Extensions.kt new file mode 100644 index 0000000..1ba8825 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/Extensions.kt @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.common + +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CaptureRequest +import android.media.MediaCodec +import android.media.MediaFormat +import android.os.Build +import android.os.Handler +import android.os.Looper +import androidx.annotation.RequiresApi +import com.pedro.common.frame.MediaFrame +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.UnsupportedEncodingException +import java.nio.ByteBuffer +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.util.concurrent.BlockingQueue +import java.util.concurrent.ExecutorService +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import kotlin.coroutines.Continuation + +/** + * Created by pedro on 3/11/23. + */ + +@Suppress("DEPRECATION") +fun MediaCodec.BufferInfo.isKeyframe(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + this.flags == MediaCodec.BUFFER_FLAG_KEY_FRAME + } else { + this.flags == MediaCodec.BUFFER_FLAG_SYNC_FRAME + } +} + +fun ByteBuffer.toByteArray(): ByteArray { + return if (this.hasArray() && !isDirect) { + this.array() + } else { + this.rewind() + val byteArray = ByteArray(this.remaining()) + this.get(byteArray) + byteArray + } +} + +fun ByteBuffer.removeInfo(info: MediaFrame.Info): ByteBuffer { + try { + position(info.offset) + limit(info.size) + } catch (ignored: Exception) { } + return slice() +} + +inline infix fun BlockingQueue.trySend(item: T): Boolean { + return try { + this.add(item) + true + } catch (e: IllegalStateException) { + false + } +} + +suspend fun onMainThread(code: () -> Unit) { + withContext(Dispatchers.Main) { + code() + } +} + +fun onMainThreadHandler(code: () -> Unit) { + Handler(Looper.getMainLooper()).post(code) +} + +fun ByteArray.bytesToHex(): String { + return joinToString("") { "%02x".format(it) } +} + +@JvmOverloads +fun ExecutorService.secureSubmit(timeout: Long = 1000, code: () -> Unit) { + try { + if (isTerminated || isShutdown) return + submit { code() }.get(timeout, TimeUnit.MILLISECONDS) + } catch (ignored: Exception) {} +} + +fun String.getMd5Hash(): String { + val md: MessageDigest + try { + md = MessageDigest.getInstance("MD5") + return md.digest(toByteArray()).bytesToHex() + } catch (ignore: NoSuchAlgorithmException) { + } catch (ignore: UnsupportedEncodingException) { + } + return "" +} + +fun newSingleThreadExecutor(queue: LinkedBlockingQueue): ExecutorService { + return ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, queue) +} + +fun getSuspendContext(): Continuation { + return object : Continuation { + override val context = Dispatchers.IO + override fun resumeWith(result: Result) {} + } +} + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +fun CameraCharacteristics.secureGet(key: CameraCharacteristics.Key): T? { + return try { get(key) } catch (e: IllegalArgumentException) { null } +} + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +fun CaptureRequest.Builder.secureGet(key: CaptureRequest.Key): T? { + return try { get(key) } catch (e: IllegalArgumentException) { null } +} + +fun String.getIndexes(char: Char): Array { + val indexes = mutableListOf() + forEachIndexed { index, c -> if (c == char) indexes.add(index) } + return indexes.toTypedArray() +} + +fun Throwable.validMessage(): String { + return (message ?: "").ifEmpty { javaClass.simpleName } +} + +fun MediaCodec.BufferInfo.toMediaFrameInfo() = MediaFrame.Info(offset, size, presentationTimeUs, isKeyframe()) + +fun ByteBuffer.clone(): ByteBuffer = ByteBuffer.wrap(toByteArray()) + +fun MediaFormat.getIntegerSafe(name: String): Int? { + return try { getInteger(name) } catch (e: Exception) { null } +} + +fun MediaFormat.getLongSafe(name: String): Long? { + return try { getLong(name) } catch (e: Exception) { null } +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/StreamEvent.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/StreamEvent.kt new file mode 100644 index 0000000..6a4edad --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/StreamEvent.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.common + +/** + * Created by pedro on 8/3/24. + */ +enum class StreamEvent { + + STARTED, CONNECTED, DISCONNECTED, FAILED, NEW_BITRATE, AUTH_ERROR, AUTH_SUCCESS +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/StreamLog.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/StreamLog.kt new file mode 100644 index 0000000..d7824c0 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/StreamLog.kt @@ -0,0 +1,8 @@ +package com.pedro.common + +/** ScreenKit privacy patch: transport command payloads and server errors are never logged. */ +@Suppress("UNUSED_PARAMETER") +object StreamLog { + fun i(tag: String, message: String): Int = 0 + fun e(tag: String, message: String, error: Throwable? = null): Int = 0 +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/TLSSocketFactory.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/TLSSocketFactory.kt new file mode 100644 index 0000000..a8bac40 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/TLSSocketFactory.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.common + +import java.io.IOException +import java.net.InetAddress +import java.net.Socket +import java.net.UnknownHostException +import java.security.SecureRandom +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager + +/** + * @author fkrauthan + */ +open class TLSSocketFactory( + trustManagers: Array? = null +): SSLSocketFactory() { + + private val internalSSLSocketFactory: SSLSocketFactory + + init { + val context = SSLContext.getInstance("TLS") + val secureRandom = if (trustManagers != null) SecureRandom() else null + context.init(null, trustManagers, secureRandom) + internalSSLSocketFactory = context.socketFactory + } + + override fun getDefaultCipherSuites(): Array { + return internalSSLSocketFactory.defaultCipherSuites + } + + override fun getSupportedCipherSuites(): Array { + return internalSSLSocketFactory.supportedCipherSuites + } + + @Throws(IOException::class) + override fun createSocket(): Socket { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket()) + } + + @Throws(IOException::class) + override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose)) + } + + @Throws(IOException::class, UnknownHostException::class) + override fun createSocket(host: String, port: Int): Socket { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) + } + + @Throws(IOException::class, UnknownHostException::class) + override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort)) + } + + @Throws(IOException::class) + override fun createSocket(host: InetAddress, port: Int): Socket { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) + } + + @Throws(IOException::class) + override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort)) + } + + private fun enableTLSOnSocket(socket: Socket): Socket { + if (socket is SSLSocket) { + socket.enabledProtocols = arrayOf("TLSv1.1", "TLSv1.2") + } + return socket + } +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/TimeUtils.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/TimeUtils.kt new file mode 100644 index 0000000..0788210 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/TimeUtils.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.common + +/** + * Created by pedro on 30/8/23. + */ +object TimeUtils { + + @JvmStatic + fun getCurrentTimeMicro(): Long = System.nanoTime() / 1000 + + @JvmStatic + fun getCurrentTimeMillis(): Long = System.currentTimeMillis() + + @JvmStatic + fun getCurrentTimeNano(): Long = System.nanoTime() +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/UrlParser.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/UrlParser.kt new file mode 100644 index 0000000..427a8b0 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/UrlParser.kt @@ -0,0 +1,108 @@ +package com.pedro.common + +import java.net.URI +import java.net.URISyntaxException +import java.util.regex.Pattern + +/** + * Created by pedro on 2/9/24. + */ +class UrlParser private constructor( + uri: URI, + private val url: String +) { + + companion object { + @Throws(URISyntaxException::class) + fun parse(endpoint: String, requiredProtocol: Array): UrlParser { + val uri = URI(endpoint) + if (uri.scheme != null && !requiredProtocol.contains(uri.scheme.trim())) { + throw URISyntaxException(endpoint, "Invalid protocol: ${uri.scheme}") + } + if (uri.userInfo != null && !uri.userInfo.contains(":")) { + throw URISyntaxException(endpoint, "Invalid auth. Auth must contain ':'") + } + if (uri.host == null) throw URISyntaxException(endpoint, "Invalid host: ${uri.host}") + if (uri.path == null) throw URISyntaxException(endpoint, "Invalid path: ${uri.host}") + return UrlParser(uri, endpoint) + } + } + + var scheme: String = "" + private set + var host: String = "" + private set + var port: Int? = null + private set + var path: String = "" + private set + var query: String? = null + private set + var auth: String? = null + private set + + init { + val url = uri.toString() + scheme = uri.scheme + host = uri.host + port = if (uri.port < 0) null else uri.port + path = uri.rawPath.removePrefix("/") + query = uri.rawQuery + auth = uri.userInfo + } + + fun getQuery(key: String): String? = getAllQueries()[key] + + fun getAuthUser(): String? { + val userInfo = auth?.split(":") ?: return null + return if (userInfo.size == 2) userInfo[0] else null + } + + fun getAuthPassword(): String? { + val userInfo = auth?.split(":") ?: return null + return if (userInfo.size == 2) userInfo[1] else null + } + + fun getAppName(): String { + val fullPath = getFullPath() + val indexes = fullPath.getIndexes('/') + return when (indexes.size) { + 0 -> fullPath + 1 -> fullPath.substring(0, indexes[0]) + else -> { + if (getAllQueries().isEmpty()) { + fullPath.substring(0, indexes[1]) + } else { + fullPath.substring(0, indexes[0]) + } + } + } + } + + fun getStreamName(): String = getFullPath().removePrefix(getAppName()).removePrefix("/") + + fun getTcUrl(): String { + val port = if (port != null) ":$port" else "" + val appName = if (getAppName().isNotEmpty()) "/${getAppName()}" else "" + return "$scheme://$host${port}${appName}" + } + + fun getFullPath(): String { + val fullPath = "$path${if (query == null) "" else "?$query"}".removePrefix("?") + if (fullPath.isEmpty()) { + val port = if (port != null) ":$port" else "" + return url.removePrefix("$scheme://$host$port").removePrefix("/") + } + return fullPath + } + + private fun getAllQueries(): Map { + val queries = query?.split("&") ?: emptyList() + val map = HashMap() + queries.forEach { entry -> + val data = entry.split(Pattern.compile("="), 2) + if (data.size == 2) map[data[0]] = data[1] + } + return map + } +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/VideoCodec.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/VideoCodec.kt new file mode 100644 index 0000000..33e319e --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/VideoCodec.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.common + +enum class VideoCodec { + H264, H265, AV1 +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/av1/Av1Parser.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/av1/Av1Parser.kt new file mode 100644 index 0000000..555aa92 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/av1/Av1Parser.kt @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.common.av1 + +import kotlin.experimental.or + +/** + * Created by pedro on 8/12/23. + * + * AV1 packets contains a sequence of OBUs. + * Each OBU contain: + * - header -> 1 to 2 bytes + * + * obu_forbidden_bit f(1) + * obu_type f(4) + * obu_extension_flag f(1) + * obu_has_size_field f(1) + * obu_reserved_1bit f(1) + * if (obu_extension_flag == 1 ) + * obu_extension_header() + * } + * + * extension header: + * + * temporal_id f(3) + * spatial_id f(2) + * extension_header_reserved_3bit f(3) + * + * - data length (optional depend of header) -> 1 to 8 bytes in leb128 + * - data + */ +class Av1Parser { + + fun getObuType(header: Byte): ObuType { + val value = ((header.toInt() and 0x7F) and 0xF8) ushr 3 + return ObuType.entries.firstOrNull { it.value == value } ?: ObuType.RESERVED + } + + fun getObus(av1Data: ByteArray): List { + val obuList = mutableListOf() + var index = 0 + while (index < av1Data.size) { + val header = readHeader(av1Data, index) + index += header.size + val leb128Value = readLeb128(av1Data, index) + val length = av1Data.sliceArray(index until index + leb128Value.second) + index += length.size + val data = av1Data.sliceArray(index until index + leb128Value.first.toInt()) + index += data.size + val obu = Obu(header, length, data) + obuList.add(obu) + } + return obuList + } + + private fun readHeader(av1Data: ByteArray, offset: Int): ByteArray { + val header = mutableListOf() + val info = av1Data[offset] + header.add(info) + val containExtended = ((info.toInt() ushr 2) and 0x01) == 1 + if (containExtended) header.add(av1Data[offset + 1]) + return header.toByteArray() + } + + private fun readLeb128(data: ByteArray, offset: Int): Pair { + var result: Long = 0 + var index = 0 + var b: Byte + do { + b = data[offset + index] + result = result or ((b.toLong() and 0x7F) shl (index * 7)) + index++ + } while (b.toInt() and 0x80 != 0) + return Pair(result, index) + } + + fun writeLeb128(length: Long) : ByteArray { + val result = mutableListOf() + var remainingValue = length + do { + var byte = (remainingValue and 0x7F).toByte() + remainingValue = remainingValue ushr 7 + if (remainingValue != 0L) { + byte = (byte or 0x80.toByte()) + } + result.add(byte) + } while (remainingValue != 0L) + return result.toByteArray() + } +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/av1/Obu.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/av1/Obu.kt new file mode 100644 index 0000000..4057569 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/av1/Obu.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.common.av1 + +/** + * Created by pedro on 8/12/23. + */ +data class Obu( + val header: ByteArray, //header and optional extension header + val leb128: ByteArray?, //this is the length value encoded in leb128 mode + val data: ByteArray +) { + fun getFullData(): ByteArray { + return header.plus(leb128 ?: byteArrayOf()).plus(data) + } +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/av1/ObuType.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/av1/ObuType.kt new file mode 100644 index 0000000..0cd18a7 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/av1/ObuType.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.common.av1 + +/** + * Created by pedro on 8/12/23. + */ +enum class ObuType(val value: Int) { + RESERVED(0), + SEQUENCE_HEADER(1), + TEMPORAL_DELIMITER(2), + FRAME_HEADER(3), + TILE_GROUP(4), + METADATA(5), + FRAME(6), + REDUNDANT_FRAME_HEADER(7), + TILE_LIST(8), + //9-14 Reserved + PADDING(15) +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/base/BaseSender.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/base/BaseSender.kt new file mode 100644 index 0000000..07b1da2 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/base/BaseSender.kt @@ -0,0 +1,148 @@ +package com.pedro.common.base + +// ScreenKit: upstream command/error logs may contain the stream key. +import com.pedro.common.StreamLog as Log +import com.pedro.common.BitrateManager +import com.pedro.common.ConnectChecker +import com.pedro.common.frame.MediaFrame +import com.pedro.common.trySend +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.nio.ByteBuffer +import java.util.concurrent.BlockingQueue +import java.util.concurrent.LinkedBlockingQueue + +abstract class BaseSender( + protected val connectChecker: ConnectChecker, + protected val TAG: String +) { + + @Volatile + protected var running = false + private var cacheSize = 200 + @Volatile + protected var queue: BlockingQueue = LinkedBlockingQueue(cacheSize) + protected var audioFramesSent: Long = 0 + protected var videoFramesSent: Long = 0 + var droppedAudioFrames: Long = 0 + protected set + var droppedVideoFrames: Long = 0 + protected set + private val bitrateManager: BitrateManager = BitrateManager(connectChecker) + protected var isEnableLogs = true + private var job: Job? = null + protected val scope = CoroutineScope(Dispatchers.IO) + @Volatile + protected var bytesSend = 0L + + abstract fun setVideoInfo(sps: ByteBuffer, pps: ByteBuffer?, vps: ByteBuffer?) + abstract fun setAudioInfo(sampleRate: Int, isStereo: Boolean) + protected abstract suspend fun onRun() + protected abstract suspend fun stopImp(clear: Boolean = true) + + fun sendMediaFrame(mediaFrame: MediaFrame) { + if (running && !queue.trySend(mediaFrame)) { + when (mediaFrame.type) { + MediaFrame.Type.VIDEO -> { + Log.i(TAG, "Video frame discarded") + droppedVideoFrames++ + } + MediaFrame.Type.AUDIO -> { + Log.i(TAG, "Audio frame discarded") + droppedAudioFrames++ + } + } + } + } + + fun start() { + bitrateManager.reset() + queue.clear() + running = true + job = scope.launch { + val bitrateTask = async { + while (scope.isActive && running) { + //bytes to bits + bitrateManager.calculateBitrate(bytesSend * 8) + bytesSend = 0 + delay(timeMillis = 1000) + } + } + onRun() + } + } + + suspend fun stop(clear: Boolean = true) { + running = false + stopImp(clear) + resetSentAudioFrames() + resetSentVideoFrames() + resetDroppedAudioFrames() + resetDroppedVideoFrames() + job?.cancelAndJoin() + job = null + queue.clear() + } + + @Throws(IllegalArgumentException::class) + fun hasCongestion(percentUsed: Float = 20f): Boolean { + if (percentUsed < 0 || percentUsed > 100) throw IllegalArgumentException("the value must be in range 0 to 100") + val size = queue.size.toFloat() + val remaining = queue.remainingCapacity().toFloat() + val capacity = size + remaining + return size >= capacity * (percentUsed / 100f) + } + + fun resizeCache(newSize: Int) { + if (newSize < queue.size - queue.remainingCapacity()) { + throw RuntimeException("Can't fit current cache inside new cache size") + } + val tempQueue: BlockingQueue = LinkedBlockingQueue(newSize) + queue.drainTo(tempQueue) + queue = tempQueue + } + + fun getCacheSize(): Int = cacheSize + + fun getItemsInCache(): Int = queue.size + + fun clearCache() { + queue.clear() + } + + fun getSentAudioFrames(): Long = audioFramesSent + + fun getSentVideoFrames(): Long = videoFramesSent + + fun resetSentAudioFrames() { + audioFramesSent = 0 + } + + fun resetSentVideoFrames() { + videoFramesSent = 0 + } + + fun resetDroppedAudioFrames() { + droppedAudioFrames = 0 + } + + fun resetDroppedVideoFrames() { + droppedVideoFrames = 0 + } + + fun setLogs(enable: Boolean) { + isEnableLogs = enable + } + + fun setBitrateExponentialFactor(factor: Float) { + bitrateManager.exponentialFactor = factor + } + + fun getBitrateExponentialFactor() = bitrateManager.exponentialFactor +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/frame/MediaFrame.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/frame/MediaFrame.kt new file mode 100644 index 0000000..32529c8 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/frame/MediaFrame.kt @@ -0,0 +1,20 @@ +package com.pedro.common.frame + +import java.nio.ByteBuffer + +data class MediaFrame( + val data: ByteBuffer, + val info: Info, + val type: Type +) { + data class Info( + val offset: Int, + val size: Int, + val timestamp: Long, + val isKeyFrame: Boolean + ) + + enum class Type { + VIDEO, AUDIO + } +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/StreamSocket.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/StreamSocket.kt new file mode 100644 index 0000000..f853818 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/StreamSocket.kt @@ -0,0 +1,29 @@ +/* + * + * * Copyright (C) 2024 pedroSG94. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package com.pedro.common.socket + +/** + * Created by pedro on 22/9/24. + */ +interface StreamSocket { + suspend fun connect() + suspend fun close() + fun isConnected(): Boolean + fun isReachable(): Boolean +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/TcpStreamSocket.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/TcpStreamSocket.kt new file mode 100644 index 0000000..47b0669 --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/TcpStreamSocket.kt @@ -0,0 +1,116 @@ +/* + * + * * Copyright (C) 2024 pedroSG94. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package com.pedro.common.socket + +import io.ktor.network.selector.SelectorManager +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.ByteWriteChannel +import io.ktor.utils.io.readByte +import io.ktor.utils.io.readFully +import io.ktor.utils.io.readUTF8Line +import io.ktor.utils.io.writeByte +import io.ktor.utils.io.writeFully +import io.ktor.utils.io.writeStringUtf8 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withTimeout +import java.net.ConnectException + +/** + * Created by pedro on 22/9/24. + */ +abstract class TcpStreamSocket: StreamSocket { + + private val timeout = 5000L + protected var input: ByteReadChannel? = null + protected var output: ByteWriteChannel? = null + protected var selectorManager = SelectorManager(Dispatchers.IO) + + suspend fun flush() { + output?.flush() + } + + suspend fun write(b: Int) = withTimeout(timeout) { + output?.writeByte(b.toByte()) + } + + suspend fun write(b: ByteArray) = withTimeout(timeout) { + output?.writeFully(b) + } + + suspend fun write(b: ByteArray, offset: Int, size: Int) = withTimeout(timeout) { + output?.writeFully(b, offset, offset + size) + } + + suspend fun writeUInt16(b: Int) { + write(byteArrayOf((b ushr 8).toByte(), b.toByte())) + } + + suspend fun writeUInt24(b: Int) { + write(byteArrayOf((b ushr 16).toByte(), (b ushr 8).toByte(), b.toByte())) + } + + suspend fun writeUInt32(b: Int) { + write(byteArrayOf((b ushr 24).toByte(), (b ushr 16).toByte(), (b ushr 8).toByte(), b.toByte())) + } + + suspend fun writeUInt32LittleEndian(b: Int) { + writeUInt32(Integer.reverseBytes(b)) + } + + suspend fun write(string: String) = withTimeout(timeout) { + output?.writeStringUtf8(string) + } + + suspend fun read(): Int = withTimeout(timeout) { + val input = input ?: throw ConnectException("Read with socket closed, broken pipe") + input.readByte().toInt() + } + + suspend fun readUInt16(): Int { + val b = ByteArray(2) + readUntil(b) + return b[0].toInt() and 0xff shl 8 or (b[1].toInt() and 0xff) + } + + suspend fun readUInt24(): Int { + val b = ByteArray(3) + readUntil(b) + return b[0].toInt() and 0xff shl 16 or (b[1].toInt() and 0xff shl 8) or (b[2].toInt() and 0xff) + } + + suspend fun readUInt32(): Int { + val b = ByteArray(4) + readUntil(b) + return b[0].toInt() and 0xff shl 24 or (b[1].toInt() and 0xff shl 16) or (b[2].toInt() and 0xff shl 8) or (b[3].toInt() and 0xff) + } + + suspend fun readUInt32LittleEndian(): Int { + return Integer.reverseBytes(readUInt32()) + } + + suspend fun readUntil(b: ByteArray) = withTimeout(timeout) { + val input = input ?: throw ConnectException("Read with socket closed, broken pipe") + input.readFully(b) + } + + suspend fun readLine(): String? = withTimeout(timeout) { + val input = input ?: throw ConnectException("Read with socket closed, broken pipe") + input.readUTF8Line() + } +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/TcpStreamSocketImp.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/TcpStreamSocketImp.kt new file mode 100644 index 0000000..806314d --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/TcpStreamSocketImp.kt @@ -0,0 +1,80 @@ +/* + * + * * Copyright (C) 2024 pedroSG94. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package com.pedro.common.socket + +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.ReadWriteSocket +import io.ktor.network.sockets.aSocket +import io.ktor.network.sockets.isClosed +import io.ktor.network.sockets.openReadChannel +import io.ktor.network.sockets.openWriteChannel +import io.ktor.network.tls.tls +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.InetAddress +import java.security.SecureRandom +import javax.net.ssl.TrustManager + +/** + * Created by pedro on 22/9/24. + */ +class TcpStreamSocketImp( + private val host: String, + private val port: Int, + private val secured: Boolean = false, + private val certificate: TrustManager? = null +): TcpStreamSocket() { + + private var socket: ReadWriteSocket? = null + private var address: InetAddress? = null + + override suspend fun connect() { + selectorManager = SelectorManager(Dispatchers.IO) + val builder = aSocket(selectorManager).tcp().connect(remoteAddress = InetSocketAddress(host, port)) + // ScreenKit: own the raw socket before TLS so handshake failure/cancellation can close it. + socket = builder + val socket = if (secured) { + builder.tls(Dispatchers.Default) { + // SNI and certificate hostname verification (Ktor only verifies names when supplied). + serverName = host.removePrefix("[").removeSuffix("]") + trustManager = certificate + random = SecureRandom() + } + } else builder + input = socket.openReadChannel() + output = socket.openWriteChannel(autoFlush = false) + address = java.net.InetSocketAddress(host, port).address + this.socket = socket + } + + override suspend fun close() = withContext(Dispatchers.IO) { + try { + address = null + input = null + output = null + socket?.close() + selectorManager.close() + } catch (ignored: Exception) {} + } + + override fun isConnected(): Boolean = socket != null && socket?.isClosed == false + + override fun isReachable(): Boolean = address?.isReachable(5000) ?: false +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/UdpStreamSocket.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/UdpStreamSocket.kt new file mode 100644 index 0000000..228363c --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/UdpStreamSocket.kt @@ -0,0 +1,88 @@ +/* + * + * * Copyright (C) 2024 pedroSG94. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package com.pedro.common.socket + +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.ConnectedDatagramSocket +import io.ktor.network.sockets.Datagram +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.aSocket +import io.ktor.network.sockets.isClosed +import io.ktor.utils.io.core.ByteReadPacket +import io.ktor.utils.io.core.remaining +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.io.readByteArray +import java.net.ConnectException +import java.net.InetAddress + +/** + * Created by pedro on 22/9/24. + */ +class UdpStreamSocket( + private val host: String, + private val port: Int, + private val sourcePort: Int? = null, + private val receiveSize: Int? = null, + private val broadcastMode: Boolean = false +): StreamSocket { + + private val address = InetSocketAddress(host, port) + private var selectorManager = SelectorManager(Dispatchers.IO) + private var socket: ConnectedDatagramSocket? = null + private var myAddress: InetAddress? = null + + override suspend fun connect() { + selectorManager = SelectorManager(Dispatchers.IO) + val builder = aSocket(selectorManager).udp() + val localAddress = if (sourcePort == null) null else InetSocketAddress("0.0.0.0", sourcePort) + val socket = builder.connect( + remoteAddress = address, + localAddress = localAddress + ) { + broadcast = broadcastMode + receiveBufferSize = receiveSize ?: 0 + } + myAddress = java.net.InetSocketAddress(host, port).address + this.socket = socket + } + + override suspend fun close() = withContext(Dispatchers.IO) { + try { + socket?.close() + selectorManager.close() + } catch (ignored: Exception) {} + } + + override fun isConnected(): Boolean = socket?.isClosed != true + + override fun isReachable(): Boolean = myAddress?.isReachable(5000) ?: false + + suspend fun readPacket(): ByteArray { + val socket = socket ?: throw ConnectException("Read with socket closed, broken pipe") + val packet = socket.receive().packet + val length = packet.remaining.toInt() + return packet.readByteArray().sliceArray(0 until length) + } + + suspend fun writePacket(bytes: ByteArray) { + val datagram = Datagram(ByteReadPacket(bytes), address) + socket?.send(datagram) + } +} diff --git a/third_party/rootencoder/common/src/test/java/com/pedro/common/StreamUrlTest.kt b/third_party/rootencoder/common/src/test/java/com/pedro/common/StreamUrlTest.kt new file mode 100644 index 0000000..86d2b53 --- /dev/null +++ b/third_party/rootencoder/common/src/test/java/com/pedro/common/StreamUrlTest.kt @@ -0,0 +1,18 @@ +package com.pedro.common + +import org.junit.Assert.assertEquals +import org.junit.Test + +class StreamUrlTest { + @Test fun escapedKeysStayByteForByteIntact() { + val parser = UrlParser.parse("rtmps://host/live/key%2Fvalue?token=a%2Bb%3D&x=1", arrayOf("rtmps")) + assertEquals("live", parser.getAppName()) + assertEquals("key%2Fvalue?token=a%2Bb%3D&x=1", parser.getStreamName()) + assertEquals("rtmps://host/live", parser.getTcUrl()) + } + @Test fun facebookStyleKeyKeepsQuery() { + val parser = UrlParser.parse("rtmps://live-api-s.facebook.com:443/rtmp/123?s_bl=1&s_sc=abc", arrayOf("rtmps")) + assertEquals("rtmp", parser.getAppName()) + assertEquals("123?s_bl=1&s_sc=abc", parser.getStreamName()) + } +} diff --git a/third_party/rootencoder/common/src/test/java/com/pedro/common/TlsSocketTest.kt b/third_party/rootencoder/common/src/test/java/com/pedro/common/TlsSocketTest.kt new file mode 100644 index 0000000..d45c4b7 --- /dev/null +++ b/third_party/rootencoder/common/src/test/java/com/pedro/common/TlsSocketTest.kt @@ -0,0 +1,61 @@ +package com.pedro.common + +import com.pedro.common.socket.TcpStreamSocketImp +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.* +import org.junit.Test +import java.security.KeyStore +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLServerSocket +import javax.net.ssl.SSLSocket +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager +import kotlin.concurrent.thread + +/** Real local TLS handshakes. The bundled test certificate is not trusted by production code. */ +class TlsSocketTest { + private fun withServer(block: (Int, X509TrustManager) -> Unit) { + val store = KeyStore.getInstance("PKCS12").apply { + TlsSocketTest::class.java.getResourceAsStream("/localhost-test-only.p12").use { load(it, "test-only".toCharArray()) } + } + val keys = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { + init(store, "test-only".toCharArray()) + } + val trust = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply { init(store) } + val context = SSLContext.getInstance("TLSv1.2").apply { init(keys.keyManagers, trust.trustManagers, null) } + val server = context.serverSocketFactory.createServerSocket(0) as SSLServerSocket + server.enabledProtocols = arrayOf("TLSv1.2") + val worker = thread(isDaemon = true) { + runCatching { (server.accept() as SSLSocket).use { socket -> socket.startHandshake(); socket.inputStream.read() } } + } + try { block(server.localPort, trust.trustManagers.filterIsInstance().first()) } + finally { server.close(); worker.join(1000) } + } + + @Test(timeout = 15000) fun acceptsTrustedMatchingHostname() = withServer { port, trust -> + runBlocking { + val socket = TcpStreamSocketImp("localhost", port, true, trust) + try { withTimeout(7000) { socket.connect() }; assertTrue(socket.isConnected()) } + finally { socket.close() } + assertFalse(socket.isConnected()) + } + } + + @Test(timeout = 15000) fun rejectsTrustedCertificateForWrongHostname() = withServer { port, trust -> + runBlocking { + val socket = TcpStreamSocketImp("127.0.0.1", port, true, trust) + try { assertTrue(runCatching { withTimeout(7000) { socket.connect() } }.isFailure) } + finally { socket.close() } + } + } + + @Test(timeout = 15000) fun rejectsUntrustedCertificate() = withServer { port, _ -> + runBlocking { + val socket = TcpStreamSocketImp("localhost", port, true, null) + try { assertTrue(runCatching { withTimeout(7000) { socket.connect() } }.isFailure) } + finally { socket.close() } + } + } +} diff --git a/third_party/rootencoder/common/src/test/resources/localhost-test-only.p12 b/third_party/rootencoder/common/src/test/resources/localhost-test-only.p12 new file mode 100644 index 0000000000000000000000000000000000000000..5e45cd9c0b2ecc29eebae04131926847b6cb5338 GIT binary patch literal 2666 zcmai$X*d*$7sdy}WRQ_v2+1%RX0nuP8)VHcLiX)unPx26%FtLqBC&ScMXAn$9SMxPJ7~YT}ib+ah1skdCO- zLJCp@=g6i(FsMJ*8|>+b0Zi+RlC_A0Fm$nU(M}pz!Pji6=Zh=(pDf^Z64W zWba#tO^4$uZ2K@FYw}j1mjnI(1Rm@o^5iA4;$7UK?hIUL2K4JH)3D3A7pJ6m_D8O1 zJ?k74l1}H3a+udZ*Go%JVf~m@U*!)5#$M&1GO8;d#4N20w_1OF7(ZtcyRtE)cD%?)zmzS?}dmQR{0w2EpJT*Sh*Wf~dJdVSg>O5%&CG>MEF7((Uw)^>q zAv9n7dq-XRf(G(PMLZ89p3S z1W|KxE79L`AReQA*yLm~%cn09P_Zsl06$(A+yB;Gna?{pL1YeW;Iq74=FOB2wpV<) z26p|_IY)8V!Z_X4U&VgHVe2AAQ5V`pZCxlu$;h86)4|B{?tmL;`EO}Gy5%}uD#fE4 z%a3Ly8x9sZo^E8$RxX1YYia~q>kv4`j$b_Z2QZ8^KpbP`salA{E30icS-RXpnvX?E5`ldl zB&EFiVxp5);A=}bMAFQtlnQk)81Rw}pfSZBP>=Tby?3*{`*)C^<7C_v z-t=e@hh)=>lV^@rqV4PX(?IFbGNcK$II9i~eS$fmJ+ls%n$NMG*nlp#FeE{TCE*HB zIBV-!t1LC8h}st-Z%Zs(z@PP#ROu=@)ThR6)_RM?zlw0+F>2!+@F)p?dYoZmSS>Yr z$4pg4PCQXET45t+`@3Vpnt0q+Pm79@|5x5PA~m0TW7(fg+hBL4Sw_9Hdw9lO&Q~>g zJo7Omdqh(st7uHHQgGxNnVF5WQD9pz?}J5NJH($Ty?lf>4Dj>Xe~wCn>ljpjbCK%Q zNQts=vBjx27p^?d|9r6a?17Edw89EUJ1p<=H}hxncWrlxOBCKWPH#uLiP#0Q}R z0coNU?R>gQ$eLNzP!4*1Y4xuLZIN3WZ1L(#jdeGXYRd!_?tIaB8S5^E9OvOUH(98F zyw}f-4tC#>#eDfY;zTB2uR^t@4|vjQb>v9`A)4NEt`{9n=~FftUq65%vdS+sM%s-% z1q#$3oU^QK^C5%_e@?xAc=AFM+&6#zNLzQbu$q&#m1o4d+K_zLZzr9#ZSg_YB~8}| zlkW0awzs(gP2AT%b$n=D&Z#}(z$fZU^IS|B*l+iJRGm#L(!CB+4Vg)k(N!|%g5_Zt z71M88@wqrZS~>dZ@tl)#Um`-T3T1)y)vPeP%EUa!kPeh7_1M_MDs@c2IB7}qg_quO zH<|3l{V~YvAMMOm-%3I|hR!R~vTkvN+YOEYs&9G^)zPYB_yx)rLi~&a!NpNAbH*pM zWp=Pz>A|F)^1^!yLi77jNN1_86dAs_m&@C9f`_)?dA@20UG@prH{spt3er*h4!BZh zPOe~foi_G)&T;*I)7?qmnax&`p4t}28nq-a*~CIk1wY^_&(Hacq*3Ch`y-2#V%n>m z=Pc3DXwV`WlzIN$hVWEmnQK-^OOtG&SkG>&g~TpHRaHSqN0s z^+?oW^y6gR2B?MZ;#IwQXrtG}u9o|;?pL^A z3U$z_#M9LwVw133#9h&i$s%qhA=96g>G4j2=CvV3)0Bl(9xAvjnk{^|iN^uukcclcVJ^x#+b zhM>hn<{kNs-d81 z8T<~ZK;d|6dznx=t8*n>-l-Ec5jSE{C~Pa{1#Lw~65Km}RJC~3+S|XP1xtZqN8fsJ z-LQog&|0nSXDPa5R$D<+?H!~Y;^HsBGIJw&fLvn*WZF8*72kx;(Q3;{%rx9@5QtFt zy~Kr-6qW$^05AYofb(g;b2|A00s!#Sa{qV20Pdb9AqWiw62bQCOQWY_1OP=E1H_gE t6kwj~ULyQzP>vKQjm(6^^6@in-8FXixVW}}XJT%@KRj?Bp8JQ;{{u(0* diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/AmfVersion.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/AmfVersion.kt new file mode 100644 index 0000000..55ac541 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/AmfVersion.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf + +/** + * Created by pedro on 8/04/21. + */ +enum class AmfVersion { + VERSION_0, VERSION_3 +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfBoolean.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfBoolean.kt new file mode 100644 index 0000000..d4fef08 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfBoolean.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v0 + +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 20/04/21. + * + * Only 1 byte of size where 0 is false and another value is true + */ +class AmfBoolean(var value: Boolean = false): AmfData() { + + @Throws(IOException::class) + override fun readBody(input: InputStream) { + val b = input.read() + this.value = b != 0 + } + + @Throws(IOException::class) + override fun writeBody(output: OutputStream) { + output.write(if (value) 1 else 0) + } + + override fun getType(): AmfType = AmfType.BOOLEAN + + override fun getSize(): Int = 1 + + override fun toString(): String { + return "AmfBoolean value: $value" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfData.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfData.kt new file mode 100644 index 0000000..33ae761 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfData.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v0 + +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 20/04/21. + */ +abstract class AmfData { + + companion object { + + /** + * Read unknown AmfData and convert it to specific class + */ + @Throws(IOException::class) + fun getAmfData(input: InputStream): AmfData { + val amfData = when (val type = getMarkType(input.read())) { + AmfType.NUMBER -> AmfNumber() + AmfType.BOOLEAN -> AmfBoolean() + AmfType.STRING -> AmfString() + AmfType.OBJECT -> AmfObject() + AmfType.NULL -> AmfNull() + AmfType.UNDEFINED -> AmfUndefined() + AmfType.ECMA_ARRAY -> AmfEcmaArray() + AmfType.STRICT_ARRAY -> AmfStrictArray() + AmfType.DATE -> AmfDate() + AmfType.LONG_STRING -> AmfLongString() + AmfType.UNSUPPORTED -> AmfUnsupported() + AmfType.XML_DOCUMENT -> AmfXmlDocument() + else -> throw IOException("Unimplemented AMF data type: ${type.name}") + } + amfData.readBody(input) + return amfData + } + + fun getMarkType(type: Int): AmfType { + return AmfType.entries.find { it.mark.toInt() == type } ?: AmfType.STRING + } + } + + @Throws(IOException::class) + fun readHeader(input: InputStream): AmfType { + return getMarkType(input.read()) + } + + @Throws(IOException::class) + fun writeHeader(output: OutputStream) { + output.write(getType().mark.toInt()) + } + + @Throws(IOException::class) + abstract fun readBody(input: InputStream) + + @Throws(IOException::class) + abstract fun writeBody(output: OutputStream) + + abstract fun getType(): AmfType + + //Body size without header type + abstract fun getSize(): Int +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfDate.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfDate.kt new file mode 100644 index 0000000..eb2b37e --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfDate.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v0 + +import com.pedro.common.TimeUtils +import com.pedro.rtmp.utils.readUntil +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.nio.ByteBuffer + +/** + * Created by pedro on 19/07/22. + * + * milliseconds from 1st Jan 1970 in UTC time zone. + * timeZone value is a reserved value that should be 0x0000 + */ +class AmfDate(var date: Double = TimeUtils.getCurrentTimeMillis().toDouble()): AmfData() { + + @Throws(IOException::class) + override fun readBody(input: InputStream) { + val bytes = ByteArray(getSize() - 2) + input.readUntil(bytes) + val value = ByteBuffer.wrap(bytes).long + date = Double.Companion.fromBits(value) + val timeZone = byteArrayOf(0x00, 0x00) + input.readUntil(timeZone) + } + + @Throws(IOException::class) + override fun writeBody(output: OutputStream) { + val byteBuffer = ByteBuffer.allocate(getSize() - 2).putLong(date.toRawBits()) + output.write(byteBuffer.array()) + val timeZone = byteArrayOf(0x00, 0x00) + output.write(timeZone) + } + + override fun getType(): AmfType = AmfType.DATE + + override fun getSize(): Int = 10 + + override fun toString(): String { + return "AmfUnsupported" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfEcmaArray.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfEcmaArray.kt new file mode 100644 index 0000000..f5b5bd4 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfEcmaArray.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v0 + +import com.pedro.rtmp.utils.readUInt32 +import com.pedro.rtmp.utils.writeUInt32 +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 20/04/21. + * + * Exactly the same that AmfObject but start with an UInt32 that indicate the number of lines in Map + */ +class AmfEcmaArray(private val properties: LinkedHashMap = LinkedHashMap()): AmfObject(properties) { + + var length = 0 + + init { + // add length size to body + bodySize += 4 + } + + override fun setProperty(name: String, data: String) { + super.setProperty(name, data) + length = properties.size + } + + override fun setProperty(name: String, data: Boolean) { + super.setProperty(name, data) + length = properties.size + } + + override fun setProperty(name: String) { + super.setProperty(name) + length = properties.size + } + + override fun setProperty(name: String, data: Double) { + super.setProperty(name, data) + length = properties.size + } + + @Throws(IOException::class) + override fun readBody(input: InputStream) { + //get number of items as UInt32 + length = input.readUInt32() + //read items + super.readBody(input) + bodySize += 4 //add length size to body + } + + @Throws(IOException::class) + override fun writeBody(output: OutputStream) { + //write number of items in the list as UInt32 + output.writeUInt32(length) + //write items + super.writeBody(output) + } + + override fun getType(): AmfType = AmfType.ECMA_ARRAY + + override fun toString(): String { + return "AmfEcmaArray length: $length, properties: $properties" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfLongString.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfLongString.kt new file mode 100644 index 0000000..4489d3b --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfLongString.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v0 + +import com.pedro.rtmp.utils.* +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 19/07/22. + * + * A string encoded in UTF-8 where 4 first bytes indicate string size + */ +open class AmfLongString(var value: String = ""): AmfData() { + + private var bodySize: Int = value.toByteArray(Charsets.UTF_8).size + 4 + + @Throws(IOException::class) + override fun readBody(input: InputStream) { + //read value size as UInt32 + bodySize = input.readUInt32() + //read value in UTF-8 + val bytes = ByteArray(bodySize) + bodySize += 4 + input.readUntil(bytes) + value = String(bytes, Charsets.UTF_8) + } + + @Throws(IOException::class) + override fun writeBody(output: OutputStream) { + val bytes = value.toByteArray(Charsets.UTF_8) + //write value size as UInt32. Value size not included + output.writeUInt32(bodySize - 4) + //write value bytes in UTF-8 + output.write(bytes) + } + + override fun getType(): AmfType = AmfType.LONG_STRING + + override fun getSize(): Int = bodySize + + override fun toString(): String { + return "AmfLongString value: $value" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfNull.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfNull.kt new file mode 100644 index 0000000..fce5d85 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfNull.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v0 + +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 20/04/21. + * + * Contain an empty body + */ +class AmfNull: AmfData() { + + @Throws(IOException::class) + override fun readBody(input: InputStream) { + //no body to read + } + + @Throws(IOException::class) + override fun writeBody(output: OutputStream) { + //no body to write + } + + override fun getType(): AmfType = AmfType.NULL + + override fun getSize(): Int = 0 + + override fun toString(): String { + return "AmfNull" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfNumber.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfNumber.kt new file mode 100644 index 0000000..48ce62f --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfNumber.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v0 + +import com.pedro.rtmp.utils.readUntil +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.nio.ByteBuffer + +/** + * Created by pedro on 20/04/21. + * + * A number in 8 bytes IEEE-754 double precision floating point value + */ +class AmfNumber(var value: Double = 0.0): AmfData() { + + @Throws(IOException::class) + override fun readBody(input: InputStream) { + val bytes = ByteArray(getSize()) + input.readUntil(bytes) + val value = ByteBuffer.wrap(bytes).long + this.value = Double.Companion.fromBits(value) + } + + @Throws(IOException::class) + override fun writeBody(output: OutputStream) { + val byteBuffer = ByteBuffer.allocate(getSize()).putLong(value.toRawBits()) + output.write(byteBuffer.array()) + } + + override fun getType(): AmfType = AmfType.NUMBER + + override fun getSize(): Int = 8 + + override fun toString(): String { + return "AmfNumber value: $value" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfObject.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfObject.kt new file mode 100644 index 0000000..856fe25 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfObject.kt @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v0 + +import java.io.BufferedInputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 20/04/21. + * + * A Map of others amf packets where key is an AmfString and value could be any amf packet + */ +open class AmfObject(private val properties: LinkedHashMap = LinkedHashMap()): AmfData() { + + protected var bodySize = 0 + + init { + properties.forEach { + //Get size of all elements and include in size value. + 1 because include header size + bodySize += it.key.getSize() + bodySize += it.value.getSize() + 1 + } + val objectEnd = AmfObjectEnd() + bodySize += objectEnd.getSize() + } + + fun getProperty(name: String): AmfData? { + properties.forEach { + if (it.key.value == name) { + return it.value + } + } + return null + } + + open fun setProperty(name: String, data: String) { + val key = AmfString(name) + val value = AmfString(data) + properties[key] = value + bodySize += key.getSize() + bodySize += value.getSize() + 1 + } + + open fun setProperty(name: String, data: Boolean) { + val key = AmfString(name) + val value = AmfBoolean(data) + properties[key] = value + bodySize += key.getSize() + bodySize += value.getSize() + 1 + } + + open fun setProperty(name: String) { + val key = AmfString(name) + val value = AmfNull() + properties[key] = value + bodySize += key.getSize() + bodySize += value.getSize() + 1 + } + + open fun setProperty(name: String, data: Double) { + val key = AmfString(name) + val value = AmfNumber(data) + properties[key] = value + bodySize += key.getSize() + bodySize += value.getSize() + 1 + } + + open fun setProperty(name: String, data: AmfData) { + val key = AmfString(name) + properties[key] = data + bodySize += key.getSize() + bodySize += data.getSize() + 1 + } + + fun getProperties() = properties + + @Throws(IOException::class) + override fun readBody(input: InputStream) { + properties.clear() + bodySize = 0 + val objectEnd = AmfObjectEnd() + val markInputStream: InputStream = if (input.markSupported()) input else BufferedInputStream(input) + while (!objectEnd.found) { + markInputStream.mark(objectEnd.getSize()) + objectEnd.readBody(input) + if (objectEnd.found) { + bodySize += objectEnd.getSize() + } else { + markInputStream.reset() + + val key = AmfString() + key.readBody(input) + bodySize += key.getSize() + + val value = getAmfData(input) + bodySize += value.getSize() + 1 + + properties[key] = value + } + } + } + + @Throws(IOException::class) + override fun writeBody(output: OutputStream) { + properties.forEach { + it.key.writeBody(output) + + it.value.writeHeader(output) + it.value.writeBody(output) + } + val objectEnd = AmfObjectEnd() + objectEnd.writeBody(output) + } + + override fun getType(): AmfType = AmfType.OBJECT + + override fun getSize(): Int = bodySize + + override fun toString(): String { + return "AmfObject properties: $properties" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfObjectEnd.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfObjectEnd.kt new file mode 100644 index 0000000..0d4581e --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfObjectEnd.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v0 + +import com.pedro.rtmp.utils.readUntil +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 20/04/21. + * + * Packet used to indicate end of AmfObject and AmfEcmaArray. + * This is a final sequence of 3 bytes. + */ +class AmfObjectEnd(var found: Boolean = false): AmfData() { + + private val endSequence = byteArrayOf(0x00, 0x00, getType().mark) + + @Throws(IOException::class) + override fun readBody(input: InputStream) { + val bytes = ByteArray(getSize()) + input.readUntil(bytes) + found = bytes contentEquals endSequence + } + + @Throws(IOException::class) + override fun writeBody(output: OutputStream) { + output.write(endSequence) + } + + override fun getType(): AmfType = AmfType.OBJECT_END + + override fun getSize(): Int = endSequence.size + + override fun toString(): String { + return "AmfObjectEnd" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfStrictArray.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfStrictArray.kt new file mode 100644 index 0000000..8ebacc3 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfStrictArray.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v0 + +import com.pedro.rtmp.utils.readUInt32 +import com.pedro.rtmp.utils.writeUInt32 +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 20/04/21. + * + * A list of any amf packets that start with an UInt32 to indicate number of items + */ +class AmfStrictArray(val items: MutableList = mutableListOf()): AmfData() { + + private var bodySize = 0 + + init { + bodySize += 4 + items.forEach { + bodySize += it.getSize() + 1 + } + } + + @Throws(IOException::class) + override fun readBody(input: InputStream) { + items.clear() + bodySize = 0 + //get number of items as UInt32 + val length = input.readUInt32() + bodySize += 4 + //read items + for (i in 0 until length) { + val amfData = getAmfData(input) + bodySize += amfData.getSize() + 1 + items.add(amfData) + } + } + + @Throws(IOException::class) + override fun writeBody(output: OutputStream) { + //write number of items in the list as UInt32 + output.writeUInt32(items.size) + //write items + items.forEach { + it.writeHeader(output) + it.writeBody(output) + } + } + + override fun getType(): AmfType = AmfType.STRICT_ARRAY + + override fun getSize(): Int = bodySize + + override fun toString(): String { + return "AmfStrictArray items: ${items.toTypedArray().contentToString()}" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfString.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfString.kt new file mode 100644 index 0000000..acea5cb --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfString.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v0 + +import com.pedro.rtmp.utils.readUInt16 +import com.pedro.rtmp.utils.readUntil +import com.pedro.rtmp.utils.writeUInt16 +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 8/04/21. + * + * A string encoded in UTF-8 where 2 first bytes indicate string size + */ +class AmfString(var value: String = ""): AmfData() { + + private var bodySize: Int = value.toByteArray(Charsets.UTF_8).size + 2 + + @Throws(IOException::class) + override fun readBody(input: InputStream) { + //read value size as UInt16 + bodySize = input.readUInt16() + //read value in UTF-8 + val bytes = ByteArray(bodySize) + bodySize += 2 + input.readUntil(bytes) + value = String(bytes, Charsets.UTF_8) + } + + @Throws(IOException::class) + override fun writeBody(output: OutputStream) { + //write value size as UInt16. Value size not included + output.writeUInt16(bodySize - 2) + //write value bytes in UTF-8 + val bytes = value.toByteArray(Charsets.UTF_8) + output.write(bytes) + } + + override fun getType(): AmfType = AmfType.STRING + + override fun getSize(): Int = bodySize + + override fun toString(): String { + return "AmfString value: $value" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfType.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfType.kt new file mode 100644 index 0000000..c9138ca --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfType.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v0 + +/** + * Created by pedro on 20/04/21. + */ +enum class AmfType(val mark: Byte) { + NUMBER(0x00), BOOLEAN(0x01), STRING(0x02), OBJECT(0x03), + NULL(0x05), UNDEFINED(0x06), ECMA_ARRAY(0x08), OBJECT_END(0x09), + STRICT_ARRAY(0x0A), DATE(0x0B), LONG_STRING(0x0C), UNSUPPORTED(0x0D), + XML_DOCUMENT(0x0F), + /** + * Not implemented + */ + REFERENCE(0x07), TYPED_OBJECT(0x10), AVM_PLUS_OBJECT(0x11), + + /** + * reserved, not supported + */ + MOVIE_CLIP(0x04), RECORD_SET(0x0E), +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfUndefined.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfUndefined.kt new file mode 100644 index 0000000..13843c8 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfUndefined.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v0 + +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 20/04/21. + * + * Contain an empty body + */ +class AmfUndefined: AmfData() { + + @Throws(IOException::class) + override fun readBody(input: InputStream) { + //no body to read + } + + @Throws(IOException::class) + override fun writeBody(output: OutputStream) { + //no body to write + } + + override fun getType(): AmfType = AmfType.UNDEFINED + + override fun getSize(): Int = 0 + + override fun toString(): String { + return "AmfUndefined" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfUnsupported.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfUnsupported.kt new file mode 100644 index 0000000..4f480fd --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfUnsupported.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v0 + +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 19/07/22. + * + * Contain an empty body + */ +class AmfUnsupported: AmfData() { + + @Throws(IOException::class) + override fun readBody(input: InputStream) { + //no body to read + } + + @Throws(IOException::class) + override fun writeBody(output: OutputStream) { + //no body to write + } + + override fun getType(): AmfType = AmfType.UNSUPPORTED + + override fun getSize(): Int = 0 + + override fun toString(): String { + return "AmfUnsupported" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfXmlDocument.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfXmlDocument.kt new file mode 100644 index 0000000..9dfa47a --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v0/AmfXmlDocument.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v0 + +import org.w3c.dom.Document +import org.xml.sax.InputSource +import org.xml.sax.SAXException +import java.io.IOException +import java.io.StringReader +import javax.xml.parsers.DocumentBuilderFactory + + +/** + * Created by pedro on 19/07/22. + * + * DOM representation of an XML document encoded as + * a long string encoded in UTF-8 where 4 first bytes indicate string size + */ +class AmfXmlDocument(value: String = ""): AmfLongString(value) { + + @Throws(IOException::class, SAXException::class) + fun getDocument(): Document { + val dom = DocumentBuilderFactory.newInstance().newDocumentBuilder() + return dom.parse(InputSource(StringReader(value))) + } + + override fun getType(): AmfType = AmfType.XML_DOCUMENT + + override fun toString(): String { + return "AmfXmlDocument value: $value" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Array.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Array.kt new file mode 100644 index 0000000..6458156 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Array.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v3 + +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 29/04/21. + */ +class Amf3Array(val items: MutableList = mutableListOf()): Amf3Data() { + + override fun readBody(input: InputStream) { + TODO("Not yet implemented") + } + + override fun writeBody(output: OutputStream) { + TODO("Not yet implemented") + } + + override fun getType(): Amf3Type = Amf3Type.ARRAY + + override fun getSize(): Int { + TODO("Not yet implemented") + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Data.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Data.kt new file mode 100644 index 0000000..a8aeda9 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Data.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v3 + +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 20/04/21. + */ +abstract class Amf3Data { + + companion object { + + /** + * Read unknown AmfData and convert it to specific class + */ + @Throws(IOException::class) + fun getAmf3Data(input: InputStream): Amf3Data { + val amf3Data = when (val type = getMark3Type(input.read())) { + Amf3Type.DOUBLE -> Amf3Double() + Amf3Type.INTEGER -> Amf3Integer() + Amf3Type.STRING -> Amf3String() + Amf3Type.OBJECT -> Amf3Object() + Amf3Type.NULL -> Amf3Null() + Amf3Type.UNDEFINED -> Amf3Undefined() + Amf3Type.ARRAY -> Amf3Array() + Amf3Type.DICTIONARY -> Amf3Dictionary() + Amf3Type.TRUE -> Amf3True() + Amf3Type.FALSE -> Amf3False() + else -> throw IOException("Unimplemented AMF3 data type: ${type.name}") + } + amf3Data.readBody(input) + return amf3Data + } + + fun getMark3Type(type: Int): Amf3Type { + return Amf3Type.entries.find { it.mark.toInt() == type } ?: Amf3Type.STRING + } + } + + @Throws(IOException::class) + fun readHeader(input: InputStream): Amf3Type { + return getMark3Type(input.read()) + } + + @Throws(IOException::class) + fun writeHeader(output: OutputStream) { + output.write(getType().mark.toInt()) + } + + @Throws(IOException::class) + abstract fun readBody(input: InputStream) + + @Throws(IOException::class) + abstract fun writeBody(output: OutputStream) + + abstract fun getType(): Amf3Type + + //Body size without header type + abstract fun getSize(): Int +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Dictionary.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Dictionary.kt new file mode 100644 index 0000000..827ac4b --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Dictionary.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v3 + +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 29/04/21. + */ +class Amf3Dictionary(private val properties: HashMap = LinkedHashMap()): Amf3Object(properties) { + + override fun readBody(input: InputStream) { + TODO("Not yet implemented") + } + + override fun writeBody(output: OutputStream) { + TODO("Not yet implemented") + } + + override fun getType(): Amf3Type = Amf3Type.DICTIONARY + + override fun getSize(): Int { + TODO("Not yet implemented") + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Double.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Double.kt new file mode 100644 index 0000000..af4a153 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Double.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v3 + +import com.pedro.rtmp.utils.readUntil +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.nio.ByteBuffer + +/** + * Created by pedro on 29/04/21. + */ +class Amf3Double(var value: Double = 0.0): Amf3Data() { + + @Throws(IOException::class) + override fun readBody(input: InputStream) { + val bytes = ByteArray(getSize()) + input.readUntil(bytes) + val value = ByteBuffer.wrap(bytes).long + this.value = Double.Companion.fromBits(value) + } + + @Throws(IOException::class) + override fun writeBody(output: OutputStream) { + val byteBuffer = ByteBuffer.allocate(getSize()).putLong(value.toRawBits()) + output.write(byteBuffer.array()) + } + + override fun getType(): Amf3Type = Amf3Type.DOUBLE + + override fun getSize(): Int = 8 +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3False.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3False.kt new file mode 100644 index 0000000..e5a3bfe --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3False.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v3 + +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 29/04/21. + */ +class Amf3False: Amf3Data() { + + override fun readBody(input: InputStream) { + //no body to read + } + + override fun writeBody(output: OutputStream) { + //no body to write + } + + override fun getType(): Amf3Type = Amf3Type.FALSE + + override fun getSize(): Int = 0 +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Integer.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Integer.kt new file mode 100644 index 0000000..1230168 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Integer.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v3 + +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 29/04/21. + */ +class Amf3Integer: Amf3Data() { + + override fun readBody(input: InputStream) { + TODO("Not yet implemented") + } + + override fun writeBody(output: OutputStream) { + TODO("Not yet implemented") + } + + override fun getType(): Amf3Type = Amf3Type.INTEGER + + override fun getSize(): Int { + TODO("Not yet implemented") + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Null.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Null.kt new file mode 100644 index 0000000..55d2c22 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Null.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v3 + +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 29/04/21. + */ +class Amf3Null: Amf3Data() { + + override fun readBody(input: InputStream) { + //no body to read + } + + override fun writeBody(output: OutputStream) { + //no body to write + } + + override fun getType(): Amf3Type = Amf3Type.NULL + + override fun getSize(): Int = 0 +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Object.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Object.kt new file mode 100644 index 0000000..57ac390 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Object.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v3 + +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 29/04/21. + */ +open class Amf3Object(private val properties: HashMap = LinkedHashMap()): Amf3Data() { + + protected var bodySize = 0 + + fun getProperty(name: String): Amf3Data? { + properties.forEach { + if (it.key.value == name) { + return it.value + } + } + return null + } + + open fun setProperty(name: String, data: String) { + val key = Amf3String(name) + val value = Amf3String(data) + properties[key] = value + bodySize += key.getSize() + bodySize += value.getSize() + 1 + } + + open fun setProperty(name: String, data: Boolean) { + val key = Amf3String(name) + val value = if (data) Amf3True() else Amf3False() + properties[key] = value + bodySize += key.getSize() + bodySize += value.getSize() + 1 + } + + open fun setProperty(name: String, data: Amf3Data) { + val key = Amf3String(name) + properties[key] = data + bodySize += key.getSize() + bodySize += data.getSize() + 1 + } + + open fun setProperty(name: String) { + val key = Amf3String(name) + val value = Amf3Null() + properties[key] = value + bodySize += key.getSize() + bodySize += value.getSize() + 1 + } + + open fun setProperty(name: String, data: Double) { + val key = Amf3String(name) + val value = Amf3Double(data) + properties[key] = value + bodySize += key.getSize() + bodySize += value.getSize() + 1 + } + + + override fun readBody(input: InputStream) { + TODO("Not yet implemented") + } + + override fun writeBody(output: OutputStream) { + TODO("Not yet implemented") + } + + override fun getType(): Amf3Type = Amf3Type.OBJECT + + override fun getSize(): Int { + TODO("Not yet implemented") + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3String.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3String.kt new file mode 100644 index 0000000..23644d9 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3String.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v3 + +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 8/04/21. + */ +class Amf3String(var value: String = ""): Amf3Data() { + + override fun readBody(input: InputStream) { + TODO("Not yet implemented") + } + + override fun writeBody(output: OutputStream) { + TODO("Not yet implemented") + } + + override fun getType(): Amf3Type = Amf3Type.STRING + + override fun getSize(): Int { + TODO("Not yet implemented") + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3True.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3True.kt new file mode 100644 index 0000000..670c04a --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3True.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v3 + +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 29/04/21. + */ +class Amf3True: Amf3Data() { + + override fun readBody(input: InputStream) { + //no body to read + } + + override fun writeBody(output: OutputStream) { + //no body to write + } + + override fun getType(): Amf3Type = Amf3Type.TRUE + + override fun getSize(): Int = 0 +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Type.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Type.kt new file mode 100644 index 0000000..b0d925b --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Type.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v3 + +/** + * Created by pedro on 29/04/21. + */ +enum class Amf3Type(val mark: Byte) { + UNDEFINED(0x00), NULL(0x01), TRUE(0x02), FALSE(0x03), INTEGER(0x04), + DOUBLE(0x05), STRING(0x06), XML_DOC(0x07), DATE(0x08), ARRAY(0x09), + OBJECT(0x0A), XML(0x0B), BYTE_ARRAY(0x0C), VECTOR_INT(0x0D), VECTOR_UINT(0x0E), + VECTOR_DOUBLE(0x0F), VECTOR_OBJECT(0x10), DICTIONARY(0x11) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Undefined.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Undefined.kt new file mode 100644 index 0000000..f61d76b --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/amf/v3/Amf3Undefined.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.amf.v3 + +import java.io.InputStream +import java.io.OutputStream + +/** + * Created by pedro on 29/04/21. + */ +class Amf3Undefined: Amf3Data() { + + override fun readBody(input: InputStream) { + //no body to read + } + + override fun writeBody(output: OutputStream) { + //no body to write + } + + override fun getType(): Amf3Type = Amf3Type.UNDEFINED + + override fun getSize(): Int = 0 +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/BasePacket.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/BasePacket.kt new file mode 100644 index 0000000..7c0038d --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/BasePacket.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv + +import com.pedro.common.frame.MediaFrame + +/** + * Created by pedro on 21/12/23. + */ +abstract class BasePacket { + + abstract suspend fun createFlvPacket(mediaFrame: MediaFrame, callback: suspend (FlvPacket) -> Unit) + abstract fun reset(resetInfo: Boolean = true) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/FlvPacket.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/FlvPacket.kt new file mode 100644 index 0000000..8766e2a --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/FlvPacket.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv + +/** + * Created by pedro on 8/04/21. + */ +data class FlvPacket( + val buffer: ByteArray = byteArrayOf(), + var timeStamp: Long = 0, + val length: Int = 0, + val type: FlvType = FlvType.AUDIO +) diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/FlvType.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/FlvType.kt new file mode 100644 index 0000000..d30f0ba --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/FlvType.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv + +/** + * Created by pedro on 29/04/21. + */ +enum class FlvType { + AUDIO, VIDEO +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioFormat.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioFormat.kt new file mode 100644 index 0000000..693eb12 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioFormat.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.audio + +/** + * Created by pedro on 29/04/21. + * list of audio codec supported by FLV + */ +enum class AudioFormat(val value: Int) { + PCM(0), ADPCM(1), MP3(2), PCM_LE(3), NELLYMOSER_16K(4), + NELLYMOSER_8K(5), NELLYMOSER(6), G711_A(7), G711_MU(8), RESERVED(9), + AAC(10), SPEEX(11), MP3_8K(14), DEVICE_SPECIFIC(15) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioObjectType.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioObjectType.kt new file mode 100644 index 0000000..3a28b13 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioObjectType.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.audio + +/** + * Created by pedro on 29/04/21. + */ +enum class AudioObjectType(val value: Int) { + UNKNOWN(0), AAC_MAIN(1), AAC_LC(2), AAC_SSR(3), AAC_LTP(4), + AAC_SBR(5), AAC_SCALABLE(6), TWINQ_VQ(7), CELP(8), HXVC(9) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioSize.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioSize.kt new file mode 100644 index 0000000..946dad6 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioSize.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.audio + +/** + * Created by pedro on 29/04/21. + */ +enum class AudioSize(val value: Int) { + SND_8_BIT(0), SND_16_BIT(1) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioSoundRate.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioSoundRate.kt new file mode 100644 index 0000000..2d70c37 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioSoundRate.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.audio + +/** + * Created by pedro on 29/04/21. + * sample rates supported + */ +enum class AudioSoundRate(val value: Int) { + SR_5_5K(0), SR_11K(1), SR_22K(2), SR_44_1K(3) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioSoundType.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioSoundType.kt new file mode 100644 index 0000000..c8caadf --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/AudioSoundType.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.audio + +/** + * Created by pedro on 29/04/21. + */ +enum class AudioSoundType(val value: Byte) { + MONO(0x00), STEREO(0x01) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/config/AudioSpecificConfig.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/config/AudioSpecificConfig.kt new file mode 100644 index 0000000..485b0d9 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/config/AudioSpecificConfig.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.audio.config + +/** + * Created by pedro on 29/04/21. + * + * ISO 14496-3 + */ +class AudioSpecificConfig(private val type: Int, private val sampleRate: Int, private val channels: Int) { + + /** supported sampleRates. */ + private val AUDIO_SAMPLING_RATES = intArrayOf( + 96000, // 0 + 88200, // 1 + 64000, // 2 + 48000, // 3 + 44100, // 4 + 32000, // 5 + 24000, // 6 + 22050, // 7 + 16000, // 8 + 12000, // 9 + 11025, // 10 + 8000, // 11 + 7350, // 12 + ) + + val size = 9 + + fun write(buffer: ByteArray, offset: Int) { + writeConfig(buffer, offset) + writeAdts(buffer, offset + 2) + } + + private fun writeConfig(buffer: ByteArray, offset: Int) { + val frequency = getFrequency() + buffer[offset] = ((type shl 3) or (frequency shr 1)).toByte() + buffer[offset + 1] = (frequency shl 7 and 0x80).plus(channels shl 3 and 0x78).toByte() + } + + private fun writeAdts(buffer: ByteArray, offset: Int) { + val frequency = getFrequency() + buffer[offset] = 0xFF.toByte() + buffer[offset + 1] = 0xF9.toByte() + buffer[offset + 2] = (((type - 1) shl 6) or (frequency shl 2) or (channels shr 2)).toByte() + buffer[offset + 3] = (((channels and 3) shl 6) or (buffer.size shr 11)).toByte() + buffer[offset + 4] = ((buffer.size and 0x7FF) shr 3).toByte() + buffer[offset + 5] = (((buffer.size and 7) shl 5).toByte()).plus(0x1F).toByte() + buffer[offset + 6] = 0xFC.toByte() + } + + private fun getFrequency(): Int { + var frequency = AUDIO_SAMPLING_RATES.indexOf(sampleRate) + //sane check, if samplerate not found using default 44100 + if (frequency == -1) frequency = 4 + return frequency + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/packet/AacPacket.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/packet/AacPacket.kt new file mode 100644 index 0000000..11f9f0d --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/packet/AacPacket.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.audio.packet + +import com.pedro.common.frame.MediaFrame +import com.pedro.common.removeInfo +import com.pedro.rtmp.flv.BasePacket +import com.pedro.rtmp.flv.FlvPacket +import com.pedro.rtmp.flv.FlvType +import com.pedro.rtmp.flv.audio.AudioFormat +import com.pedro.rtmp.flv.audio.AudioObjectType +import com.pedro.rtmp.flv.audio.AudioSize +import com.pedro.rtmp.flv.audio.AudioSoundRate +import com.pedro.rtmp.flv.audio.AudioSoundType +import com.pedro.rtmp.flv.audio.config.AudioSpecificConfig +import kotlin.experimental.or + +/** + * Created by pedro on 8/04/21. + */ +class AacPacket: BasePacket() { + + private val header = ByteArray(2) + //first time we need send audio config + private var configSend = false + + private var sampleRate = 44100 + private var isStereo = true + //In microphone we are using always 16bits pcm encoding. Change me if needed + private var audioSize = AudioSize.SND_16_BIT + //In encoder we are using always AAC LC. Change me if needed + private val objectType = AudioObjectType.AAC_LC + + enum class Type(val mark: Byte) { + SEQUENCE(0x00), RAW(0x01) + } + + fun sendAudioInfo(sampleRate: Int, isStereo: Boolean, audioSize: AudioSize = AudioSize.SND_16_BIT) { + this.sampleRate = sampleRate + this.isStereo = isStereo + this.audioSize = audioSize + } + + override suspend fun createFlvPacket( + mediaFrame: MediaFrame, + callback: suspend (FlvPacket) -> Unit + ) { + val fixedBuffer = mediaFrame.data.removeInfo(mediaFrame.info) + //header is 2 bytes length + //4 bits sound format, 2 bits sound rate, 1 bit sound size, 1 bit sound type + //8 bits sound data (always 10 because we are using aac) + val type = if (isStereo) AudioSoundType.STEREO.value else AudioSoundType.MONO.value + val soundRate = when (sampleRate) { + 44100 -> AudioSoundRate.SR_44_1K + 22050 -> AudioSoundRate.SR_22K + 11025 -> AudioSoundRate.SR_11K + 5500 -> AudioSoundRate.SR_5_5K + else -> AudioSoundRate.SR_44_1K + } + header[0] = type or (audioSize.value shl 1).toByte() or (soundRate.value shl 2).toByte() or (AudioFormat.AAC.value shl 4).toByte() + val buffer: ByteArray + if (!configSend) { + val config = AudioSpecificConfig(objectType.value, sampleRate, if (isStereo) 2 else 1) + buffer = ByteArray(config.size + header.size) + header[1] = Type.SEQUENCE.mark + config.write(buffer, header.size) + configSend = true + } else { + header[1] = Type.RAW.mark + buffer = ByteArray(fixedBuffer.remaining() + header.size) + fixedBuffer.get(buffer, header.size, fixedBuffer.remaining()) + } + System.arraycopy(header, 0, buffer, 0, header.size) + val ts = mediaFrame.info.timestamp / 1000 + callback(FlvPacket(buffer, ts, buffer.size, FlvType.AUDIO)) + } + + override fun reset(resetInfo: Boolean) { + configSend = false + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/packet/G711Packet.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/packet/G711Packet.kt new file mode 100644 index 0000000..112dd02 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/audio/packet/G711Packet.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.audio.packet + +import com.pedro.common.frame.MediaFrame +import com.pedro.common.removeInfo +import com.pedro.rtmp.flv.BasePacket +import com.pedro.rtmp.flv.FlvPacket +import com.pedro.rtmp.flv.FlvType +import com.pedro.rtmp.flv.audio.AudioFormat +import com.pedro.rtmp.flv.audio.AudioSize +import com.pedro.rtmp.flv.audio.AudioSoundRate +import com.pedro.rtmp.flv.audio.AudioSoundType +import kotlin.experimental.or + +/** + * Created by pedro on 21/12/23. + */ +class G711Packet: BasePacket() { + + private val header = ByteArray(1) + //In microphone we are using always 16bits pcm encoding. Change me if needed + private var audioSize = AudioSize.SND_16_BIT + + fun sendAudioInfo(audioSize: AudioSize = AudioSize.SND_16_BIT) { + this.audioSize = audioSize + } + + override suspend fun createFlvPacket( + mediaFrame: MediaFrame, + callback: suspend (FlvPacket) -> Unit + ) { + val fixedBuffer = mediaFrame.data.removeInfo(mediaFrame.info) + //header is 1 byte length + //4 bits sound format, 2 bits sound rate, 1 bit sound size, 1 bit sound type + //sound rate should be ignored because G711 only support 8k so we are using 5_5k by default + header[0] = AudioSoundType.MONO.value or (audioSize.value shl 1).toByte() or + (AudioSoundRate.SR_5_5K.value shl 2).toByte() or (AudioFormat.G711_A.value shl 4).toByte() + val buffer = ByteArray(fixedBuffer.remaining() + header.size) + fixedBuffer.get(buffer, header.size, fixedBuffer.remaining()) + System.arraycopy(header, 0, buffer, 0, header.size) + val ts = mediaFrame.info.timestamp / 1000 + callback(FlvPacket(buffer, ts, buffer.size, FlvType.AUDIO)) + } + + override fun reset(resetInfo: Boolean) { + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/FourCCPacketType.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/FourCCPacketType.kt new file mode 100644 index 0000000..7882f4a --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/FourCCPacketType.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.video + +/** + * Created by pedro on 14/08/23. + * + */ +enum class FourCCPacketType(val value: Int) { + SEQUENCE_START(0), + CODED_FRAMES(1), + SEQUENCE_END(2), + CODED_FRAMES_X(3), + METADATA(4), + MPEG_2_TS_SEQUENCE_START(5) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/VideoDataType.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/VideoDataType.kt new file mode 100644 index 0000000..976d08b --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/VideoDataType.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.video + +/** + * Created by pedro on 1/05/21. + */ +enum class VideoDataType(val value: Int) { + KEYFRAME(1), INTER_FRAME(2) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/VideoFormat.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/VideoFormat.kt new file mode 100644 index 0000000..0a4f3b4 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/VideoFormat.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.video + +/** + * Created by pedro on 29/04/21. + */ +enum class VideoFormat(val value: Int) { + SORENSON_H263(2), SCREEN_1(3), VP6(4), VP6_ALPHA(5), + SCREEN_2(6), AVC(7), UNKNOWN(255), + //fourCC extension + HEVC(1752589105), // { "h", "v", "c", "1" } + AV1(1635135537), // { "a", "v", "0", "1" } + VP9(1987063865) // { "v", "p", "0", "9" } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/VideoNalType.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/VideoNalType.kt new file mode 100644 index 0000000..cfeb110 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/VideoNalType.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.video + +/** + * Created by pedro on 29/04/21. + */ +enum class VideoNalType(val value: Int) { + UNSPEC(0), SLICE(1), DPA(2), DPB(3), DPC(4), IDR(5), SEI(6), + SPS(7), PPS(8), AUD(9), EO_SEQ(10), EO_STREAM(11), FILL(12), + HEVC_VPS(32), HEVC_SPS(33), HEVC_PPS(34), + //H265 IDR + IDR_N_LP(20), IDR_W_DLP(19) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/SPSH265Parser.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/SPSH265Parser.kt new file mode 100644 index 0000000..44da5d6 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/SPSH265Parser.kt @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.video.config + +import com.pedro.rtmp.utils.BitBuffer +import java.nio.ByteBuffer + +/** + * Created by pedro on 16/08/23. + * + * ISO/IEC 23008-2 7.3.2.2.1 + * + */ +class SPSH265Parser { + var generalProfileSpace = 0 + var generalTierFlag = 0 + var generalProfileIdc = 0 + var generalProfileCompatibilityFlags = 0 + var generalConstraintIndicatorFlags = 0L + var generalLevelIdc = 0 + var chromaFormat = 0 + var bitDepthLumaMinus8 = 0 + var bitDepthChromaMinus8 = 0 + + fun parse(sps: ByteArray) { + parse(ByteBuffer.wrap(sps)) + } + + fun parse(sps: ByteBuffer) { + val rbsp = BitBuffer.extractRbsp(sps, 2) + val bitBuffer = BitBuffer(rbsp) + //Dropping nal_unit_header + bitBuffer.getLong(16) + //sps_video_parameter_set_id + bitBuffer.get(4) + //sps_max_sub_layers_minus1 + val maxSubLayersMinus1 = bitBuffer.get(3) + //sps_temporal_id_nesting_flag + bitBuffer.get(1) + //start profile_tier_level + generalProfileSpace = bitBuffer.get(2).toInt() + generalTierFlag = if (bitBuffer.getBool()) 1 else 0 + generalProfileIdc = bitBuffer.getShort(5).toInt() + + generalProfileCompatibilityFlags = bitBuffer.getInt(32) + generalConstraintIndicatorFlags = bitBuffer.getLong(48) + generalLevelIdc = bitBuffer.get(8).toInt() + + val subLayerProfilePresentFlag = mutableListOf() + val subLayerLevelPresentFlag = mutableListOf() + for (i in 0 until maxSubLayersMinus1) { + subLayerProfilePresentFlag.add(bitBuffer.getBool()) + subLayerLevelPresentFlag.add(bitBuffer.getBool()) + } + + if (maxSubLayersMinus1 > 0) { + for (i in maxSubLayersMinus1..8) { + bitBuffer.getLong(2) // reserved_zero_2bits + } + } + + for (i in 0 until maxSubLayersMinus1) { + if (subLayerProfilePresentFlag[i]) { + bitBuffer.getLong(32) // skip + bitBuffer.getLong(32) // skip + bitBuffer.getLong(24) // skip + } + + if (subLayerLevelPresentFlag[i]) { + bitBuffer.getLong(8) // skip + } + } + //end profile_tier_level + + //sps_seq_parameter_set_id + bitBuffer.readUE() + //chroma_format_idc + chromaFormat = bitBuffer.readUE() + if (chromaFormat == 3) { + //separate_colour_plane_flag + bitBuffer.getBool() + } + //pic_width_in_luma_samples + bitBuffer.readUE() + //pic_height_in_luma_samples + bitBuffer.readUE() + //conformance_window_flag + val conformanceWindowFlag = bitBuffer.getBool() + if (conformanceWindowFlag) { + //conf_win_left_offset + bitBuffer.readUE() + //conf_win_right_offset + bitBuffer.readUE() + //conf_win_top_offset + bitBuffer.readUE() + //conf_win_bottom_offset + bitBuffer.readUE() + } + //bit_depth_luma_minus8 + bitDepthLumaMinus8 = bitBuffer.readUE() + //bit_depth_chroma_minus8 + bitDepthChromaMinus8 = bitBuffer.readUE() + + //The buffer continue but we don't need read more + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/VideoSpecificConfigAV1.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/VideoSpecificConfigAV1.kt new file mode 100644 index 0000000..c147fef --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/VideoSpecificConfigAV1.kt @@ -0,0 +1,274 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.video.config + +import com.pedro.common.BitBuffer +import com.pedro.common.av1.Av1Parser +import java.nio.ByteBuffer + +/** + * Created by pedro on 8/12/23. + * aligned(8) class AV1CodecConfigurationRecord + * { + * unsigned int(1) marker = 1; + * unsigned int(7) version = 1; + * unsigned int(3) seq_profile; + * unsigned int(5) seq_level_idx_0; + * unsigned int(1) seq_tier_0; + * unsigned int(1) high_bitdepth; + * unsigned int(1) twelve_bit; + * unsigned int(1) monochrome; + * unsigned int(1) chroma_subsampling_x; + * unsigned int(1) chroma_subsampling_y; + * unsigned int(2) chroma_sample_position; + * unsigned int(3) reserved = 0; + * + * unsigned int(1) initial_presentation_delay_present; + * if(initial_presentation_delay_present) { + * unsigned int(4) initial_presentation_delay_minus_one; + * } else { + * unsigned int(4) reserved = 0; + * } + * + * unsigned int(8) configOBUs[]; + * } + * + */ +class VideoSpecificConfigAV1(private val sequenceObu: ByteArray) { + + private val av1Parser = Av1Parser() + val size = 4 + sequenceObu.size + + fun write(buffer: ByteArray, offset: Int) { + val obuData = av1Parser.getObus(sequenceObu)[0].data + val bitBuffer = BitBuffer(obuData) + var index = 0 + + val seqProfile = bitBuffer.getBits(index, 3) + index += 3 + index += 1 + val reducedStillPictureHeader = bitBuffer.getBits(index, 1) + index += 1 + var seqLevelIdx = 0 + var seqTier = 0 + var initialDisplayDelayPresentFlag = 0 + var initialPresentationDelay = 0 + if (reducedStillPictureHeader == 1) { + seqLevelIdx = bitBuffer.getBits(index, 5) + index += 5 + } else { + val timingInfoPresentFlag = bitBuffer.getBits(index, 1) + index += 1 + var decoderModelInfoPresentFlag = 0 + var bufferDelayLengthMinus1 = 0 + if (timingInfoPresentFlag == 1) { + index += 64 + val equalPictureInterval = bitBuffer.getBits(index, 1) + index += 1 + if (equalPictureInterval == 1) { + val uvlc = readUVLC(obuData, index) + index += uvlc.second + } + decoderModelInfoPresentFlag = bitBuffer.getBits(index, 1) + index += 1 + if (decoderModelInfoPresentFlag == 1) { + bufferDelayLengthMinus1 = bitBuffer.getBits(index, 5) + index += 5 + index += 42 //skip this + } + } + initialDisplayDelayPresentFlag = bitBuffer.getBits(index, 1) + index += 1 + val operatingPointsCntMinus1 = bitBuffer.getBits(index, 5) + index += 5 + for (i in 0..operatingPointsCntMinus1) { + index += 12 //skip + val levelIdx = bitBuffer.getBits(index, 5) + index += 5 + if (i == 0) seqLevelIdx = levelIdx + if (levelIdx > 7) { + val sTier = bitBuffer.getBits(index, 1) + index += 1 + if (i == 0) seqTier = sTier + } + if (decoderModelInfoPresentFlag == 1) { + val decoderModelPresentForThisOp = bitBuffer.getBits(index, 1) + index += 1 + if (decoderModelPresentForThisOp == 1) { + val n = bufferDelayLengthMinus1 + 1 + index += n * 2 + 1 //skip this + } + } + if (initialDisplayDelayPresentFlag == 1) { + val initialDisplayDelayPresentForThisOp = bitBuffer.getBits(index, 1) + index += 1 + if (initialDisplayDelayPresentForThisOp == 1) { + val initialDisplayDelayMinus1 = bitBuffer.getBits(index, 4) + index += 4 + if (i == 0) initialPresentationDelay = initialDisplayDelayMinus1 + } + } + } + } + + val frameWidthBitsMinus1 = bitBuffer.getBits(index, 4) + index += 4 + val frameHeightBitsMinus1 = bitBuffer.getBits(index, 4) + index += 4 + index += frameWidthBitsMinus1 + 1 + frameHeightBitsMinus1 + 1 + var frameIdNumbersPresentFlag = 0 + if (reducedStillPictureHeader != 1) { + frameIdNumbersPresentFlag = bitBuffer.getBits(index, 1) + index += 1 + } + if (frameIdNumbersPresentFlag == 1) index += 7 + index += 3 + if (reducedStillPictureHeader != 1) { + index += 4 + val enableOrderHint = bitBuffer.getBits(index, 1) + index += 1 + if (enableOrderHint == 1) index += 2 + val seqChooseScreenContentTools = bitBuffer.getBits(index, 1) + index += 1 + var seqForceScreenContentTools = 2 + if (seqChooseScreenContentTools != 1) { + seqForceScreenContentTools = bitBuffer.getBits(index, 1) + index += 1 + } + if (seqForceScreenContentTools > 0) { + val seqChooseIntegerMv = bitBuffer.getBits(index, 1) + index += 1 + if (seqChooseIntegerMv != 1) index += 1 + } + if (enableOrderHint == 1) index += 3 + } + index += 3 + //config color + val highBitDepth = bitBuffer.getBits(index, 1) + index += 1 + var twelveBit = 0 + var bitDepth = 0 + if (seqProfile == 2 && highBitDepth == 1) { + twelveBit = bitBuffer.getBits(index, 1) + index += 1 + bitDepth = if (twelveBit == 1) 12 else 10 + } else if (seqProfile <= 2) { + bitDepth = if (highBitDepth == 1) 10 else 8 + } + val monochrome = if (seqProfile == 1) { + 0 + } else { + val chrome = bitBuffer.getBits(index, 1) + index += 1 + chrome + } + val colorDescriptionPresentFlag = bitBuffer.getBits(index, 1) + index += 1 + var colorPrimaries = 0 + var transferCharacteristics = 0 + var matrixCoefficients = 0 + if (colorDescriptionPresentFlag == 1) { + colorPrimaries = bitBuffer.getBits(index, 8) + index += 8 + transferCharacteristics = bitBuffer.getBits(index, 8) + index += 8 + matrixCoefficients = bitBuffer.getBits(index, 8) + index += 8 + } + val subsamplingX: Int + val subsamplingY: Int + var samplePosition = 0 + if (monochrome == 1) { + index += 1 + subsamplingX = 1 + subsamplingY = 1 + } else if (colorPrimaries == 1 && transferCharacteristics == 1 && matrixCoefficients == 1) { + subsamplingX = 0 + subsamplingY = 0 + } else { + index += 1 + if (seqProfile == 0) { + subsamplingX = 1 + subsamplingY = 1 + } else if (seqProfile == 1) { + subsamplingX = 0 + subsamplingY = 0 + } else { + if (bitDepth == 12) { + subsamplingX = bitBuffer.getBits(index, 1) + index += 1 + if (subsamplingX == 1) { + subsamplingY = bitBuffer.getBits(index, 1) + index += 1 + } else { + subsamplingY = 0 + } + } else { + subsamplingX = 1 + subsamplingY = 0 + } + if (subsamplingX == 1 && subsamplingY == 1) { + samplePosition = bitBuffer.getBits(index, 1) + index += 1 + } + } + } + index += 1 + //finish config color + index += 1 + val data = ByteBuffer.wrap(buffer, offset, size) + data.put(0x81.toByte()) //marker and version + data.put(((seqProfile shl 5) or seqLevelIdx).toByte()) + data.put( + ((seqTier shl 7) or (highBitDepth shl 6) or (twelveBit shl 5) or (monochrome shl 4) or + (subsamplingX shl 3) or (subsamplingY shl 2) or samplePosition).toByte() + ) + val reserved = 0 + data.put(((reserved shl 5) or (initialDisplayDelayPresentFlag shl 4) or initialPresentationDelay).toByte()) + data.put(sequenceObu) + } + + private fun readUVLC(byteArray: ByteArray, offset: Int): Pair { + var leadingZeros = 0 + var value = 0 + var currentIndex = offset / 8 + var currentBit = 7 - offset % 8 + + while (byteArray[currentIndex].toInt() and (1 shl currentBit) == 0) { + leadingZeros++ + if (currentBit == 0) { + currentIndex++ + currentBit = 7 + } else { + currentBit-- + } + } + + for (i in 0 until leadingZeros + 1) { + if (currentBit == 0) { + currentIndex++ + currentBit = 7 + } else { + currentBit-- + } + + value = (value shl 1) or ((byteArray[currentIndex].toInt() ushr currentBit) and 1) + } + + return Pair(value, offset + leadingZeros + 1) + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/VideoSpecificConfigAVC.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/VideoSpecificConfigAVC.kt new file mode 100644 index 0000000..9c552e3 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/VideoSpecificConfigAVC.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.video.config + +import java.nio.ByteBuffer + +/** + * Created by pedro on 29/04/21. + * + * ISO/IEC 14496-15 + * + * AVCDecoderConfigurationRecord + * + * 5 bytes sps/pps header: + * 1 byte configurationVersion (always 1), 1 byte AVCProfileIndication, 1 byte profile_compatibility, + * 1 byte AVCLevelIndication, 1 byte lengthSizeMinusOneWithReserved (always 0xff) + * 3 bytes size of sps: + * 1 byte numOfSequenceParameterSetsWithReserved (always 0xe1), 2 bytes sequenceParameterSetLength(2B) (sps size) + * N bytes of sps. + * sequenceParameterSetNALUnit (sps data) + * 3 bytes size of pps: + * 1 byte numOfPictureParameterSets (always 1), 2 bytes pictureParameterSetLength (pps size) + * N bytes of pps: + * pictureParameterSetNALUnit (pps data) + */ +class VideoSpecificConfigAVC(private val sps: ByteArray, private val pps: ByteArray) { + + val size = calculateSize(sps, pps) + + fun write(buffer: ByteArray, offset: Int) { + val data = ByteBuffer.wrap(buffer, offset, size) + //5 bytes sps/pps header + data.put(0x01) + val profileIdc = sps[1] + data.put(profileIdc) + val profileCompatibility = sps[2] + data.put(profileCompatibility) + val levelIdc = sps[3] + data.put(levelIdc) + data.put(0xff.toByte()) + //3 bytes size of sps + data.put(0xe1.toByte()) + data.putShort(sps.size.toShort()) + //N bytes of sps + data.put(sps) + //3 bytes size of pps + data.put(0x01) + data.putShort(pps.size.toShort()) + //N bytes of pps + data.put(pps) + } + + private fun calculateSize(sps: ByteArray, pps: ByteArray): Int { + return 5 + 3 + sps.size + 3 + pps.size + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/VideoSpecificConfigHEVC.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/VideoSpecificConfigHEVC.kt new file mode 100644 index 0000000..cc35b52 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/config/VideoSpecificConfigHEVC.kt @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.video.config + +import com.pedro.rtmp.utils.toByteArray +import java.nio.ByteBuffer + +/** + * Created by pedro on 14/08/23. + * + * ISO/IEC 14496-15 8.3.3.1.2 + * + * HEVCDecoderConfigurationRecord + * + * aligned(8) class HEVCDecoderConfigurationRecord { + * unsigned int(8) configurationVersion = 1; + * unsigned int(2) general_profile_space; + * unsigned int(1) general_tier_flag; + * unsigned int(5) general_profile_idc; + * unsigned int(32) general_profile_compatibility_flags; + * unsigned int(48) general_constraint_indicator_flags; + * unsigned int(8) general_level_idc; + * bit(4) reserved = ‘1111’b; + * unsigned int(12) min_spatial_segmentation_idc; + * bit(6) reserved = ‘111111’b; + * unsigned int(2) parallelismType; + * bit(6) reserved = ‘111111’b; + * unsigned int(2) chroma_format_idc; + * bit(5) reserved = ‘11111’b; + * unsigned int(3) bit_depth_luma_minus8; + * bit(5) reserved = ‘11111’b; + * unsigned int(3) bit_depth_chroma_minus8; + * bit(16) avgFrameRate; + * bit(2) constantFrameRate; + * bit(3) numTemporalLayers; + * bit(1) temporalIdNested; + * unsigned int(2) lengthSizeMinusOne; + * unsigned int(8) numOfArrays; + * for (j=0; j < numOfArrays; j++) { + * bit(1) array_completeness; + * unsigned int(1) reserved = 0; + * unsigned int(6) NAL_unit_type; + * unsigned int(16) numNalus; + * for (i=0; i< numNalus; i++) { + * unsigned int(16) nalUnitLength; + * bit(8*nalUnitLength) nalUnit; + * } + * } + * } + */ +class VideoSpecificConfigHEVC( + private val sps: ByteArray, + private val pps: ByteArray, + private val vps: ByteArray, +) { + + val size = calculateSize(sps, pps, vps) + + fun write(buffer: ByteArray, offset: Int) { + val data = ByteBuffer.wrap(buffer, offset, size) + + val configurationVersion = 1 + data.put(configurationVersion.toByte()) + + val spsParsed = SPSH265Parser() + spsParsed.parse(sps) + + val generalProfileSpace = spsParsed.generalProfileSpace + val generalTierFlag = spsParsed.generalTierFlag + val generalProfileIdc = spsParsed.generalProfileIdc + val combined = (generalProfileSpace shl 6) or (generalTierFlag shl 5) or generalProfileIdc + data.put(combined.toByte()) + + val generalProfileCompatibilityFlags = spsParsed.generalProfileCompatibilityFlags + data.putInt(generalProfileCompatibilityFlags) + + val generalConstraintIndicatorFlags = spsParsed.generalConstraintIndicatorFlags + data.put(generalConstraintIndicatorFlags.toByteArray().sliceArray(2 until Long.SIZE_BYTES)) + + val generalLevelIdc = spsParsed.generalLevelIdc + data.put(generalLevelIdc.toByte()) + + val minSpatialSegmentationIdc = 0 //should be extracted from VUI. It is not relevant so using 0 by default + data.putShort((0xf000 or minSpatialSegmentationIdc).toShort()) + + val parallelismType = 0 + data.put((0xfc or parallelismType).toByte()) + + val chromaFormatIdc = spsParsed.chromaFormat + data.put((0xfc or chromaFormatIdc).toByte()) + + val bitDepthLumaMinus8 = spsParsed.bitDepthLumaMinus8 + data.put((0xf8 or bitDepthLumaMinus8).toByte()) + + val bitDepthChromaMinus8 = spsParsed.bitDepthChromaMinus8 + data.put((0xf8 or bitDepthChromaMinus8).toByte()) + + val avgFrameRate = 0 + data.putShort(avgFrameRate.toShort()) + + val constantFrameRate = 0 + val numTemporalLayers = 0 + val temporalIdNested = 0 + val lengthSizeMinusOne = 3 //that means that we are using nalu size of 4 + val combined2 = (constantFrameRate shl 6) or (numTemporalLayers shl 3) or (temporalIdNested shl 2) or lengthSizeMinusOne + data.put(combined2.toByte()) + + data.put(0x03) //8 bits num of arrays + //Arrays + //array vps + writeNaluArray(0x20, vps, data) + //array sps + writeNaluArray(0x21, sps, data) + //array pps + writeNaluArray(0x22, pps, data) + } + + private fun writeNaluArray(type: Byte, naluByteBuffer: ByteArray, buffer: ByteBuffer) { + val arrayCompleteness = 1 + val reserved = 0 + buffer.put(((arrayCompleteness shl 7) or (reserved shl 6) or type.toInt()).toByte()) + val numNalus = 1 + buffer.putShort(numNalus.toShort()) + buffer.putShort(naluByteBuffer.size.toShort()) + buffer.put(naluByteBuffer) + } + + private fun calculateSize(sps: ByteArray, pps: ByteArray, vps: ByteArray): Int { + return 23 + 5 + vps.size + 5 + sps.size + 5 + pps.size + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/Av1Packet.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/Av1Packet.kt new file mode 100644 index 0000000..39f6a83 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/Av1Packet.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.video.packet + +// ScreenKit: upstream command/error logs may contain the stream key. +import com.pedro.common.StreamLog as Log +import com.pedro.common.av1.Av1Parser +import com.pedro.common.av1.ObuType +import com.pedro.common.frame.MediaFrame +import com.pedro.common.removeInfo +import com.pedro.common.toByteArray +import com.pedro.rtmp.flv.BasePacket +import com.pedro.rtmp.flv.FlvPacket +import com.pedro.rtmp.flv.FlvType +import com.pedro.rtmp.flv.video.FourCCPacketType +import com.pedro.rtmp.flv.video.VideoDataType +import com.pedro.rtmp.flv.video.VideoFormat +import com.pedro.rtmp.flv.video.config.VideoSpecificConfigAV1 +import java.nio.ByteBuffer + +/** + * Created by pedro on 05/12/23. + * + */ +class Av1Packet: BasePacket() { + + private val TAG = "AV1Packet" + + private val parser = Av1Parser() + private val header = ByteArray(5) + //first time we need send video config + private var configSend = false + private var obuSequence: ByteArray? = null + + fun sendVideoInfo(obuSequence: ByteBuffer) { + this.obuSequence = obuSequence.toByteArray() + } + + override suspend fun createFlvPacket( + mediaFrame: MediaFrame, + callback: suspend (FlvPacket) -> Unit + ) { + var fixedBuffer = mediaFrame.data.duplicate().removeInfo(mediaFrame.info) + val ts = mediaFrame.info.timestamp / 1000 + + //header is 8 bytes length: + //mark first byte as extended header (0b10000000) + //4 bits data type, 4 bits packet type + //4 bytes extended codec type (in this case av01) + //3 bytes CompositionTime, the cts. + val codec = VideoFormat.AV1.value // { "a", "v", "0", "1" } + header[1] = (codec shr 24).toByte() + header[2] = (codec shr 16).toByte() + header[3] = (codec shr 8).toByte() + header[4] = codec.toByte() + + var buffer: ByteArray + if (!configSend) { + header[0] = (0b10000000 or (VideoDataType.KEYFRAME.value shl 4) or FourCCPacketType.SEQUENCE_START.value).toByte() + val obuSequence = this.obuSequence + if (obuSequence != null) { + val config = VideoSpecificConfigAV1(obuSequence) + buffer = ByteArray(config.size + header.size) + config.write(buffer, header.size) + } else { + Log.e(TAG, "waiting for a valid av1ConfigurationRecord") + return + } + + System.arraycopy(header, 0, buffer, 0, header.size) + callback(FlvPacket(buffer, ts, buffer.size, FlvType.VIDEO)) + configSend = true + } + //remove temporal delimitered OBU if found on start + if (parser.getObuType(fixedBuffer.get(0)) == ObuType.TEMPORAL_DELIMITER) { + fixedBuffer.position(2) + fixedBuffer = fixedBuffer.slice() + } + + fixedBuffer.rewind() + val size = fixedBuffer.remaining() + buffer = ByteArray(header.size + size) + + val nalType = if (mediaFrame.info.isKeyFrame) VideoDataType.KEYFRAME.value else VideoDataType.INTER_FRAME.value + header[0] = (0b10000000 or (nalType shl 4) or FourCCPacketType.CODED_FRAMES.value).toByte() + fixedBuffer.get(buffer, header.size, size) + + System.arraycopy(header, 0, buffer, 0, header.size) + callback(FlvPacket(buffer, ts, buffer.size, FlvType.VIDEO)) + } + + override fun reset(resetInfo: Boolean) { + if (resetInfo) { + obuSequence = null + } + configSend = false + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/AvcAccessUnit.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/AvcAccessUnit.kt new file mode 100644 index 0000000..6c97ad7 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/AvcAccessUnit.kt @@ -0,0 +1,38 @@ +package com.pedro.rtmp.flv.video.packet + +/** ScreenKit: each NAL in an Annex-B access unit needs its own AVCC length prefix. */ +internal object AvcAccessUnit { + fun split(bytes: ByteArray): List { + fun startCodeAt(i: Int): Int = when { + i + 3 < bytes.size && bytes[i] == 0.toByte() && bytes[i + 1] == 0.toByte() && bytes[i + 2] == 0.toByte() && bytes[i + 3] == 1.toByte() -> 4 + i + 2 < bytes.size && bytes[i] == 0.toByte() && bytes[i + 1] == 0.toByte() && bytes[i + 2] == 1.toByte() -> 3 + else -> 0 + } + if (bytes.isEmpty()) return emptyList() + val result = mutableListOf() + if (startCodeAt(0) == 0) { + // Some encoders already produce four-byte AVCC lengths. + var offset = 0 + while (offset + 4 <= bytes.size) { + val length = java.nio.ByteBuffer.wrap(bytes, offset, 4).int + require(length > 0 && length <= bytes.size - offset - 4) { "Invalid AVC length" } + result += bytes.copyOfRange(offset + 4, offset + 4 + length) + offset += 4 + length + } + require(offset == bytes.size) { "Truncated AVC access unit" } + return result + } + var start = startCodeAt(0) + var cursor = start + while (cursor < bytes.size) { + val size = startCodeAt(cursor) + if (size > 0) { + if (cursor > start) result += bytes.copyOfRange(start, cursor) + start = cursor + size + cursor = start + } else cursor++ + } + if (start < bytes.size) result += bytes.copyOfRange(start, bytes.size) + return result + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/H264Packet.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/H264Packet.kt new file mode 100644 index 0000000..baea2e9 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/H264Packet.kt @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.video.packet + +// ScreenKit: upstream command/error logs may contain the stream key. +import com.pedro.common.StreamLog as Log +import com.pedro.common.frame.MediaFrame +import com.pedro.common.removeInfo +import com.pedro.rtmp.flv.BasePacket +import com.pedro.rtmp.flv.FlvPacket +import com.pedro.rtmp.flv.FlvType +import com.pedro.rtmp.flv.video.VideoDataType +import com.pedro.rtmp.flv.video.VideoFormat +import com.pedro.rtmp.flv.video.VideoNalType +import com.pedro.rtmp.flv.video.config.VideoSpecificConfigAVC +import java.nio.ByteBuffer + +/** + * Created by pedro on 8/04/21. + * + * ISO 14496-15 + */ +class H264Packet: BasePacket() { + + private val TAG = "H264Packet" + + private val header = ByteArray(5) + //first time we need send video config + private var configSend = false + + private var sps: ByteArray? = null + private var pps: ByteArray? = null + + enum class Type(val value: Byte) { + SEQUENCE(0x00), NALU(0x01), EO_SEQ(0x02) + } + + fun sendVideoInfo(sps: ByteBuffer, pps: ByteBuffer) { + val mSps = removeHeader(sps) + val mPps = removeHeader(pps) + + val spsBytes = ByteArray(mSps.remaining()) + val ppsBytes = ByteArray(mPps.remaining()) + mSps.get(spsBytes, 0, spsBytes.size) + mPps.get(ppsBytes, 0, ppsBytes.size) + + this.sps = spsBytes + this.pps = ppsBytes + } + + override suspend fun createFlvPacket( + mediaFrame: MediaFrame, + callback: suspend (FlvPacket) -> Unit + ) { + val fixedBuffer = mediaFrame.data.removeInfo(mediaFrame.info) + val ts = mediaFrame.info.timestamp / 1000 + //header is 5 bytes length: + //4 bits FrameType, 4 bits CodecID + //1 byte AVCPacketType + //3 bytes CompositionTime, the cts. + val cts = 0 + header[2] = (cts shr 16).toByte() + header[3] = (cts shr 8).toByte() + header[4] = cts.toByte() + + var buffer: ByteArray + if (!configSend) { + header[0] = ((VideoDataType.KEYFRAME.value shl 4) or VideoFormat.AVC.value).toByte() + header[1] = Type.SEQUENCE.value + + val sps = this.sps + val pps = this.pps + if (sps != null && pps != null) { + val config = VideoSpecificConfigAVC(sps, pps) + buffer = ByteArray(config.size + header.size) + config.write(buffer, header.size) + } else { + Log.e(TAG, "waiting for a valid sps and pps") + return + } + + System.arraycopy(header, 0, buffer, 0, header.size) + callback(FlvPacket(buffer, ts, buffer.size, FlvType.VIDEO)) + configSend = true + } + // ScreenKit: codecs can emit SEI + multiple slices (or repeated SPS/PPS) in one buffer. + // Treating the entire access unit as one NAL produces an invalid FLV AVC packet. + val bytes = ByteArray(fixedBuffer.remaining()).also { fixedBuffer.get(it) } + val nals = AvcAccessUnit.split(bytes).filter { nal -> + val type = nal[0].toInt() and 0x1f + type != VideoNalType.SPS.value && type != VideoNalType.PPS.value + } + if (nals.isEmpty()) return + val keyframe = mediaFrame.info.isKeyFrame || nals.any { (it[0].toInt() and 0x1f) == VideoNalType.IDR.value } + val nalType = if (keyframe) VideoDataType.KEYFRAME.value else VideoDataType.INTER_FRAME.value + header[0] = ((nalType shl 4) or VideoFormat.AVC.value).toByte() + header[1] = Type.NALU.value + val packet = ByteBuffer.allocate(header.size + nals.sumOf { 4 + it.size }) + packet.put(header) + nals.forEach { packet.putInt(it.size); packet.put(it) } + buffer = packet.array() + callback(FlvPacket(buffer, ts, buffer.size, FlvType.VIDEO)) + } + + private fun removeHeader(byteBuffer: ByteBuffer): ByteBuffer { + val data = byteBuffer.duplicate() + val prefix = when { + data.remaining() >= 4 && data.getInt(data.position()) == 1 -> 4 + data.remaining() >= 3 && data.get(data.position()) == 0.toByte() && + data.get(data.position() + 1) == 0.toByte() && data.get(data.position() + 2) == 1.toByte() -> 3 + else -> 0 + } + data.position(data.position() + prefix) + return data.slice() + } + + override fun reset(resetInfo: Boolean) { + if (resetInfo) { + sps = null + pps = null + } + configSend = false + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/H265Packet.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/H265Packet.kt new file mode 100644 index 0000000..aeb23f2 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/flv/video/packet/H265Packet.kt @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.flv.video.packet + +// ScreenKit: upstream command/error logs may contain the stream key. +import com.pedro.common.StreamLog as Log +import com.pedro.common.frame.MediaFrame +import com.pedro.common.removeInfo +import com.pedro.rtmp.flv.BasePacket +import com.pedro.rtmp.flv.FlvPacket +import com.pedro.rtmp.flv.FlvType +import com.pedro.rtmp.flv.video.FourCCPacketType +import com.pedro.rtmp.flv.video.VideoDataType +import com.pedro.rtmp.flv.video.VideoFormat +import com.pedro.rtmp.flv.video.VideoNalType +import com.pedro.rtmp.flv.video.config.VideoSpecificConfigHEVC +import java.nio.ByteBuffer + +/** + * Created by pedro on 14/08/23. + * + */ +class H265Packet: BasePacket() { + + private val TAG = "H265Packet" + + private val header = ByteArray(8) + private val naluSize = 4 + //first time we need send video config + private var configSend = false + + private var sps: ByteArray? = null + private var pps: ByteArray? = null + private var vps: ByteArray? = null + + fun sendVideoInfo(sps: ByteBuffer, pps: ByteBuffer, vps: ByteBuffer) { + val mSps = removeHeader(sps) + val mPps = removeHeader(pps) + val mVps = removeHeader(vps) + + val spsBytes = ByteArray(mSps.remaining()) + val ppsBytes = ByteArray(mPps.remaining()) + val vpsBytes = ByteArray(mVps.remaining()) + mSps.get(spsBytes, 0, spsBytes.size) + mPps.get(ppsBytes, 0, ppsBytes.size) + mVps.get(vpsBytes, 0, vpsBytes.size) + + this.sps = spsBytes + this.pps = ppsBytes + this.vps = vpsBytes + } + + override suspend fun createFlvPacket( + mediaFrame: MediaFrame, + callback: suspend (FlvPacket) -> Unit + ) { + val fixedBuffer = mediaFrame.data.removeInfo(mediaFrame.info) + val ts = mediaFrame.info.timestamp / 1000 + //header is 8 bytes length: + //mark first byte as extended header (0b10000000) + //4 bits data type, 4 bits packet type + //4 bytes extended codec type (in this case hevc) + //3 bytes CompositionTime, the cts. + val codec = VideoFormat.HEVC.value // { "h", "v", "c", "1" } + header[1] = (codec shr 24).toByte() + header[2] = (codec shr 16).toByte() + header[3] = (codec shr 8).toByte() + header[4] = codec.toByte() + val cts = 0 + val ctsLength = 3 + header[5] = (cts shr 16).toByte() + header[6] = (cts shr 8).toByte() + header[7] = cts.toByte() + + val packets = mutableListOf() + var buffer: ByteArray + if (!configSend) { + //avoid send cts on sequence start + header[0] = (0b10000000 or (VideoDataType.KEYFRAME.value shl 4) or FourCCPacketType.SEQUENCE_START.value).toByte() + val sps = this.sps + val pps = this.pps + val vps = this.vps + if (sps != null && pps != null && vps != null) { + val config = VideoSpecificConfigHEVC(sps, pps, vps) + buffer = ByteArray(config.size + header.size - ctsLength) + config.write(buffer, header.size - ctsLength) + } else { + Log.e(TAG, "waiting for a valid sps and pps") + return + } + + System.arraycopy(header, 0, buffer, 0, header.size - ctsLength) + callback(FlvPacket(buffer, ts, buffer.size, FlvType.VIDEO)) + configSend = true + } + val headerSize = getHeaderSize(fixedBuffer) + if (headerSize == 0) return //invalid buffer or waiting for sps/pps + fixedBuffer.rewind() + val validBuffer = removeHeader(fixedBuffer, headerSize) + val size = validBuffer.remaining() + buffer = ByteArray(header.size + size + naluSize) + + val type: Int = validBuffer.get(0).toInt().shr(1 and 0x3f) + var nalType = VideoDataType.INTER_FRAME.value + if (type == VideoNalType.IDR_N_LP.value || type == VideoNalType.IDR_W_DLP.value || mediaFrame.info.isKeyFrame) { + nalType = VideoDataType.KEYFRAME.value + } else if (type == VideoNalType.HEVC_VPS.value || type == VideoNalType.HEVC_SPS.value || type == VideoNalType.HEVC_PPS.value) { + // we don't need send it because we already do it in video config + return + } + header[0] = (0b10000000 or (nalType shl 4) or FourCCPacketType.CODED_FRAMES.value).toByte() + writeNaluSize(buffer, header.size, size) + validBuffer.get(buffer, header.size + naluSize, size) + + System.arraycopy(header, 0, buffer, 0, header.size) + callback(FlvPacket(buffer, ts, buffer.size, FlvType.VIDEO)) + } + + //naluSize = UInt32 + private fun writeNaluSize(buffer: ByteArray, offset: Int, size: Int) { + buffer[offset] = (size ushr 24).toByte() + buffer[offset + 1] = (size ushr 16).toByte() + buffer[offset + 2] = (size ushr 8).toByte() + buffer[offset + 3] = size.toByte() + } + + private fun removeHeader(byteBuffer: ByteBuffer, size: Int = -1): ByteBuffer { + val position = if (size == -1) getStartCodeSize(byteBuffer) else size + byteBuffer.position(position) + return byteBuffer.slice() + } + + private fun getHeaderSize(byteBuffer: ByteBuffer): Int { + if (byteBuffer.remaining() < 4) return 0 + + val sps = this.sps + val pps = this.pps + val vps = this.vps + if (sps != null && pps != null && vps != null) { + val startCodeSize = getStartCodeSize(byteBuffer) + if (startCodeSize == 0) return 0 + val startCode = ByteArray(startCodeSize) { 0x00 } + startCode[startCodeSize - 1] = 0x01 + val avcHeader = startCode.plus(vps).plus(startCode).plus(sps).plus(startCode).plus(pps).plus(startCode) + if (byteBuffer.remaining() < avcHeader.size) return startCodeSize + + val possibleAvcHeader = ByteArray(avcHeader.size) + byteBuffer.get(possibleAvcHeader, 0, possibleAvcHeader.size) + return if (avcHeader.contentEquals(possibleAvcHeader)) { + avcHeader.size + } else { + startCodeSize + } + } + return 0 + } + + private fun getStartCodeSize(byteBuffer: ByteBuffer): Int { + var startCodeSize = 0 + if (byteBuffer.get(0).toInt() == 0x00 && byteBuffer.get(1).toInt() == 0x00 + && byteBuffer.get(2).toInt() == 0x00 && byteBuffer.get(3).toInt() == 0x01) { + //match 00 00 00 01 + startCodeSize = 4 + } else if (byteBuffer.get(0).toInt() == 0x00 && byteBuffer.get(1).toInt() == 0x00 + && byteBuffer.get(2).toInt() == 0x01) { + //match 00 00 01 + startCodeSize = 3 + } + return startCodeSize + } + + override fun reset(resetInfo: Boolean) { + if (resetInfo) { + sps = null + pps = null + vps = null + } + configSend = false + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManager.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManager.kt new file mode 100644 index 0000000..e05cd81 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManager.kt @@ -0,0 +1,238 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp + +// ScreenKit: upstream command/error logs may contain the stream key. +import com.pedro.common.StreamLog as Log +import com.pedro.common.AudioCodec +import com.pedro.common.TimeUtils +import com.pedro.common.VideoCodec +import com.pedro.rtmp.amf.v0.* +import com.pedro.rtmp.flv.FlvPacket +import com.pedro.rtmp.rtmp.message.* +import com.pedro.rtmp.rtmp.message.control.Event +import com.pedro.rtmp.rtmp.message.control.Type +import com.pedro.rtmp.rtmp.message.control.UserControl +import com.pedro.rtmp.utils.CommandSessionHistory +import com.pedro.rtmp.utils.RtmpConfig +import com.pedro.rtmp.utils.socket.RtmpSocket +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.* + +/** + * Created by pedro on 21/04/21. + */ +abstract class CommandsManager { + + protected val TAG = "CommandsManager" + + val sessionHistory = CommandSessionHistory() + var timestamp = 0 + protected var commandId = 0 + var streamId = 0 + var host = "" + var port = 1935 + var appName = "" + var streamName = "" + var tcUrl = "" + var flashVersion: String = "FMLE/3.0 (compatible; Lavf57.56.101)" + var user: String? = null + var password: String? = null + var onAuth = false + var incrementalTs = false + var startTs = 0L + var readChunkSize = RtmpConfig.DEFAULT_CHUNK_SIZE + var audioDisabled = false + var videoDisabled = false + private var bytesRead = 0 + private var acknowledgementSequence = 0 + + protected var width = 640 + protected var height = 480 + var fps = 30 + protected var sampleRate = 44100 + protected var isStereo = true + var videoCodec = VideoCodec.H264 + var audioCodec = AudioCodec.AAC + //Avoid write a packet in middle of other. + private val writeSync = Mutex(locked = false) + + fun setVideoResolution(width: Int, height: Int) { + this.width = width + this.height = height + } + + fun setAudioInfo(sampleRate: Int, isStereo: Boolean) { + this.sampleRate = sampleRate + this.isStereo = isStereo + } + + fun setAuth(user: String?, password: String?) { + this.user = user + this.password = password + } + + protected fun getCurrentTimestamp(): Int { + return (TimeUtils.getCurrentTimeMillis() / 1000 - timestamp).toInt() + } + + @Throws(IOException::class) + suspend fun sendChunkSize(socket: RtmpSocket) { + writeSync.withLock { + if (RtmpConfig.writeChunkSize != RtmpConfig.DEFAULT_CHUNK_SIZE) { + val chunkSize = SetChunkSize(RtmpConfig.writeChunkSize) + chunkSize.header.timeStamp = getCurrentTimestamp() + chunkSize.header.messageStreamId = streamId + chunkSize.writeHeader(socket) + chunkSize.writeBody(socket) + socket.flush() + Log.i(TAG, "send $chunkSize") + } else { + Log.i(TAG, "using default write chunk size ${RtmpConfig.DEFAULT_CHUNK_SIZE}") + } + } + } + + @Throws(IOException::class) + suspend fun sendConnect(auth: String, socket: RtmpSocket) { + writeSync.withLock { + sendConnectImp(auth, socket) + socket.flush() + } + } + + @Throws(IOException::class) + suspend fun createStream(socket: RtmpSocket) { + writeSync.withLock { + createStreamImp(socket) + socket.flush() + } + } + + @Throws(IOException::class) + suspend fun readMessageResponse(socket: RtmpSocket): RtmpMessage { + val message = RtmpMessage.getRtmpMessage(socket, readChunkSize, sessionHistory) + sessionHistory.setReadHeader(message.header) + Log.i(TAG, "read $message") + bytesRead += message.header.getPacketLength() + return message + } + + @Throws(IOException::class) + suspend fun sendMetadata(socket: RtmpSocket) { + writeSync.withLock { + sendMetadataImp(socket) + socket.flush() + } + } + + @Throws(IOException::class) + suspend fun sendPublish(socket: RtmpSocket) { + writeSync.withLock { + sendPublishImp(socket) + socket.flush() + } + } + + @Throws(IOException::class) + suspend fun sendWindowAcknowledgementSize(socket: RtmpSocket) { + writeSync.withLock { + val windowAcknowledgementSize = WindowAcknowledgementSize(RtmpConfig.acknowledgementWindowSize, getCurrentTimestamp()) + windowAcknowledgementSize.writeHeader(socket) + windowAcknowledgementSize.writeBody(socket) + socket.flush() + } + } + + suspend fun sendPong(event: Event, socket: RtmpSocket) { + writeSync.withLock { + val pong = UserControl(Type.PONG_REPLY, event) + pong.writeHeader(socket) + pong.writeBody(socket) + socket.flush() + Log.i(TAG, "send pong") + } + } + + @Throws(IOException::class) + suspend fun sendClose(socket: RtmpSocket) { + writeSync.withLock { + sendCloseImp(socket) + socket.flush() + } + } + + suspend fun checkAndSendAcknowledgement(socket: RtmpSocket) { + writeSync.withLock { + if (bytesRead >= RtmpConfig.acknowledgementWindowSize) { + acknowledgementSequence += bytesRead + bytesRead -= RtmpConfig.acknowledgementWindowSize + val acknowledgement = Acknowledgement(acknowledgementSequence) + acknowledgement.writeHeader(socket) + acknowledgement.writeBody(socket) + socket.flush() + Log.i(TAG, "send $acknowledgement") + } + } + } + + @Throws(IOException::class) + suspend fun sendVideoPacket(flvPacket: FlvPacket, socket: RtmpSocket): Int { + writeSync.withLock { + if (incrementalTs) { + flvPacket.timeStamp = ((TimeUtils.getCurrentTimeNano() / 1000 - startTs) / 1000) + } + val video = Video(flvPacket, streamId) + video.writeHeader(socket) + video.writeBody(socket) + socket.flush(true) + return video.header.getPacketLength() //get packet size with header included to calculate bps + } + } + + @Throws(IOException::class) + suspend fun sendAudioPacket(flvPacket: FlvPacket, socket: RtmpSocket): Int { + writeSync.withLock { + if (incrementalTs) { + flvPacket.timeStamp = ((TimeUtils.getCurrentTimeNano() / 1000 - startTs) / 1000) + } + val audio = Audio(flvPacket, streamId) + audio.writeHeader(socket) + audio.writeBody(socket) + socket.flush(true) + return audio.header.getPacketLength() //get packet size with header included to calculate bps + } + } + + abstract suspend fun sendConnectImp(auth: String, socket: RtmpSocket) + abstract suspend fun createStreamImp(socket: RtmpSocket) + abstract suspend fun sendMetadataImp(socket: RtmpSocket) + abstract suspend fun sendPublishImp(socket: RtmpSocket) + abstract suspend fun sendCloseImp(socket: RtmpSocket) + + fun reset() { + startTs = 0 + timestamp = 0 + streamId = 0 + commandId = 0 + readChunkSize = RtmpConfig.DEFAULT_CHUNK_SIZE + sessionHistory.reset() + acknowledgementSequence = 0 + bytesRead = 0 + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf0.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf0.kt new file mode 100644 index 0000000..43ccc84 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf0.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp + +// ScreenKit: upstream command/error logs may contain the stream key. +import com.pedro.common.StreamLog as Log +import com.pedro.common.AudioCodec +import com.pedro.common.VideoCodec +import com.pedro.rtmp.amf.v0.AmfData +import com.pedro.rtmp.amf.v0.AmfEcmaArray +import com.pedro.rtmp.amf.v0.AmfNull +import com.pedro.rtmp.amf.v0.AmfObject +import com.pedro.rtmp.amf.v0.AmfStrictArray +import com.pedro.rtmp.amf.v0.AmfString +import com.pedro.rtmp.flv.audio.AudioFormat +import com.pedro.rtmp.flv.video.VideoFormat +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.rtmp.message.BasicHeader +import com.pedro.rtmp.rtmp.message.command.CommandAmf0 +import com.pedro.rtmp.rtmp.message.data.DataAmf0 +import com.pedro.rtmp.utils.socket.RtmpSocket + +class CommandsManagerAmf0: CommandsManager() { + override suspend fun sendConnectImp(auth: String, socket: RtmpSocket) { + val connect = CommandAmf0("connect", ++commandId, getCurrentTimestamp(), streamId, + BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_CONNECTION.mark)) + val connectInfo = AmfObject() + connectInfo.setProperty("app", appName + auth) + connectInfo.setProperty("flashVer", flashVersion) + connectInfo.setProperty("tcUrl", tcUrl + auth) + if (!videoDisabled) { + if (videoCodec == VideoCodec.H265) { + val list = mutableListOf() + list.add(AmfString("hvc1")) + val array = AmfStrictArray(list) + connectInfo.setProperty("fourCcList", array) + } else if (videoCodec == VideoCodec.AV1) { + val list = mutableListOf() + list.add(AmfString("av01")) + val array = AmfStrictArray(list) + connectInfo.setProperty("fourCcList", array) + } + } + connectInfo.setProperty("objectEncoding", 0.0) + connect.addData(connectInfo) + + connect.writeHeader(socket) + connect.writeBody(socket) + sessionHistory.setPacket(commandId, "connect") + Log.i(TAG, "send $connect") + } + + override suspend fun createStreamImp(socket: RtmpSocket) { + val releaseStream = CommandAmf0("releaseStream", ++commandId, getCurrentTimestamp(), streamId, + BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_STREAM.mark)) + releaseStream.addData(AmfNull()) + releaseStream.addData(AmfString(streamName)) + + releaseStream.writeHeader(socket) + releaseStream.writeBody(socket) + sessionHistory.setPacket(commandId, "releaseStream") + Log.i(TAG, "send $releaseStream") + + val fcPublish = CommandAmf0("FCPublish", ++commandId, getCurrentTimestamp(), streamId, + BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_STREAM.mark)) + fcPublish.addData(AmfNull()) + fcPublish.addData(AmfString(streamName)) + + fcPublish.writeHeader(socket) + fcPublish.writeBody(socket) + sessionHistory.setPacket(commandId, "FCPublish") + Log.i(TAG, "send $fcPublish") + + val createStream = CommandAmf0("createStream", ++commandId, getCurrentTimestamp(), streamId, + BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_CONNECTION.mark)) + createStream.addData(AmfNull()) + + createStream.writeHeader(socket) + createStream.writeBody(socket) + sessionHistory.setPacket(commandId, "createStream") + Log.i(TAG, "send $createStream") + } + + override suspend fun sendMetadataImp(socket: RtmpSocket) { + val name = "@setDataFrame" + val metadata = DataAmf0(name, getCurrentTimestamp(), streamId) + metadata.addData(AmfString("onMetaData")) + val amfEcmaArray = AmfEcmaArray() + amfEcmaArray.setProperty("duration", 0.0) + if (!videoDisabled) { + amfEcmaArray.setProperty("width", width.toDouble()) + amfEcmaArray.setProperty("height", height.toDouble()) + //few servers don't support it even if it is in the standard rtmp enhanced + val codecValue = when (videoCodec) { + VideoCodec.H264 -> VideoFormat.AVC.value + VideoCodec.H265 -> VideoFormat.HEVC.value + VideoCodec.AV1 -> VideoFormat.AV1.value + } + amfEcmaArray.setProperty("videocodecid", codecValue.toDouble()) + amfEcmaArray.setProperty("framerate", fps.toDouble()) + amfEcmaArray.setProperty("videodatarate", 0.0) + } + if (!audioDisabled) { + val codecValue = when (audioCodec) { + AudioCodec.G711 -> AudioFormat.G711_A.value + AudioCodec.AAC -> AudioFormat.AAC.value + AudioCodec.OPUS -> throw IllegalArgumentException("Unsupported codec: ${audioCodec.name}") + } + amfEcmaArray.setProperty("audiocodecid", codecValue.toDouble()) + amfEcmaArray.setProperty("audiosamplerate", sampleRate.toDouble()) + amfEcmaArray.setProperty("audiosamplesize", 16.0) + amfEcmaArray.setProperty("audiodatarate", 0.0) + amfEcmaArray.setProperty("stereo", isStereo) + } + amfEcmaArray.setProperty("filesize", 0.0) + metadata.addData(amfEcmaArray) + + metadata.writeHeader(socket) + metadata.writeBody(socket) + Log.i(TAG, "send $metadata") + } + + override suspend fun sendPublishImp(socket: RtmpSocket) { + val name = "publish" + val publish = CommandAmf0(name, ++commandId, getCurrentTimestamp(), streamId, + BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_STREAM.mark)) + publish.addData(AmfNull()) + publish.addData(AmfString(streamName)) + publish.addData(AmfString("live")) + + publish.writeHeader(socket) + publish.writeBody(socket) + sessionHistory.setPacket(commandId, name) + Log.i(TAG, "send $publish") + } + + override suspend fun sendCloseImp(socket: RtmpSocket) { + val name = "closeStream" + val closeStream = CommandAmf0(name, ++commandId, getCurrentTimestamp(), streamId, BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_STREAM.mark)) + closeStream.addData(AmfNull()) + + closeStream.writeHeader(socket) + closeStream.writeBody(socket) + sessionHistory.setPacket(commandId, name) + Log.i(TAG, "send $closeStream") + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf3.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf3.kt new file mode 100644 index 0000000..3e4aa5c --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/CommandsManagerAmf3.kt @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp + +// ScreenKit: upstream command/error logs may contain the stream key. +import com.pedro.common.StreamLog as Log +import com.pedro.common.VideoCodec +import com.pedro.rtmp.amf.v3.Amf3Array +import com.pedro.rtmp.amf.v3.Amf3Data +import com.pedro.rtmp.amf.v3.Amf3Dictionary +import com.pedro.rtmp.amf.v3.Amf3Null +import com.pedro.rtmp.amf.v3.Amf3Object +import com.pedro.rtmp.amf.v3.Amf3String +import com.pedro.rtmp.flv.audio.AudioFormat +import com.pedro.rtmp.flv.video.VideoFormat +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.rtmp.message.BasicHeader +import com.pedro.rtmp.rtmp.message.command.CommandAmf3 +import com.pedro.rtmp.rtmp.message.data.DataAmf3 +import com.pedro.rtmp.utils.socket.RtmpSocket + +class CommandsManagerAmf3: CommandsManager() { + override suspend fun sendConnectImp(auth: String, socket: RtmpSocket) { + val connect = CommandAmf3("connect", ++commandId, getCurrentTimestamp(), streamId, + BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_CONNECTION.mark)) + val connectInfo = Amf3Object() + connectInfo.setProperty("app", appName + auth) + connectInfo.setProperty("flashVer", flashVersion) + connectInfo.setProperty("tcUrl", tcUrl + auth) + if (!videoDisabled) { + if (videoCodec == VideoCodec.H265) { + val list = mutableListOf() + list.add(Amf3String("hvc1")) + val array = Amf3Array(list) + connectInfo.setProperty("fourCcList", array) + } else if (videoCodec == VideoCodec.AV1) { + val list = mutableListOf() + list.add(Amf3String("av01")) + val array = Amf3Array(list) + connectInfo.setProperty("fourCcList", array) + } + } + connectInfo.setProperty("objectEncoding", 3.0) + connect.addData(connectInfo) + + connect.writeHeader(socket) + connect.writeBody(socket) + sessionHistory.setPacket(commandId, "connect") + Log.i(TAG, "send $connect") + } + + override suspend fun createStreamImp(socket: RtmpSocket) { + val releaseStream = CommandAmf3("releaseStream", ++commandId, getCurrentTimestamp(), streamId, + BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_STREAM.mark)) + releaseStream.addData(Amf3Null()) + releaseStream.addData(Amf3String(streamName)) + + releaseStream.writeHeader(socket) + releaseStream.writeBody(socket) + sessionHistory.setPacket(commandId, "releaseStream") + Log.i(TAG, "send $releaseStream") + + val fcPublish = CommandAmf3("FCPublish", ++commandId, getCurrentTimestamp(), streamId, + BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_STREAM.mark)) + fcPublish.addData(Amf3Null()) + fcPublish.addData(Amf3String(streamName)) + + fcPublish.writeHeader(socket) + fcPublish.writeBody(socket) + sessionHistory.setPacket(commandId, "FCPublish") + Log.i(TAG, "send $fcPublish") + + val createStream = CommandAmf3("createStream", ++commandId, getCurrentTimestamp(), streamId, + BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_CONNECTION.mark)) + createStream.addData(Amf3Null()) + + createStream.writeHeader(socket) + createStream.writeBody(socket) + sessionHistory.setPacket(commandId, "createStream") + Log.i(TAG, "send $createStream") + } + + override suspend fun sendMetadataImp(socket: RtmpSocket) { + val name = "@setDataFrame" + val metadata = DataAmf3(name, getCurrentTimestamp(), streamId) + metadata.addData(Amf3String("onMetaData")) + val amfEcmaArray = Amf3Dictionary() + amfEcmaArray.setProperty("duration", 0.0) + if (!videoDisabled) { + amfEcmaArray.setProperty("width", width.toDouble()) + amfEcmaArray.setProperty("height", height.toDouble()) + //few servers don't support it even if it is in the standard rtmp enhanced + //val codecValue = if (videoCodec == VideoCodec.H265) VideoFormat.HEVC.value else VideoFormat.AVC.value + //amfEcmaArray.setProperty("videocodecid", codecValue.toDouble()) + amfEcmaArray.setProperty("videocodecid", VideoFormat.AVC.value.toDouble()) + amfEcmaArray.setProperty("framerate", fps.toDouble()) + amfEcmaArray.setProperty("videodatarate", 0.0) + } + if (!audioDisabled) { + amfEcmaArray.setProperty("audiocodecid", AudioFormat.AAC.value.toDouble()) + amfEcmaArray.setProperty("audiosamplerate", sampleRate.toDouble()) + amfEcmaArray.setProperty("audiosamplesize", 16.0) + amfEcmaArray.setProperty("audiodatarate", 0.0) + amfEcmaArray.setProperty("stereo", isStereo) + } + amfEcmaArray.setProperty("filesize", 0.0) + metadata.addData(amfEcmaArray) + + metadata.writeHeader(socket) + metadata.writeBody(socket) + Log.i(TAG, "send $metadata") + } + + override suspend fun sendPublishImp(socket: RtmpSocket) { + val name = "publish" + val publish = CommandAmf3(name, ++commandId, getCurrentTimestamp(), streamId, + BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_STREAM.mark)) + publish.addData(Amf3Null()) + publish.addData(Amf3String(streamName)) + publish.addData(Amf3String("live")) + + publish.writeHeader(socket) + publish.writeBody(socket) + sessionHistory.setPacket(commandId, name) + Log.i(TAG, "send $publish") + } + + override suspend fun sendCloseImp(socket: RtmpSocket) { + val name = "closeStream" + val closeStream = CommandAmf3(name, ++commandId, getCurrentTimestamp(), streamId, BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_STREAM.mark)) + closeStream.addData(Amf3Null()) + + closeStream.writeHeader(socket) + closeStream.writeBody(socket) + sessionHistory.setPacket(commandId, name) + Log.i(TAG, "send $closeStream") + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/Handshake.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/Handshake.kt new file mode 100644 index 0000000..9b4720e --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/Handshake.kt @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp + +// ScreenKit: upstream command/error logs may contain the stream key. +import com.pedro.common.StreamLog as Log +import com.pedro.common.TimeUtils +import com.pedro.rtmp.utils.socket.RtmpSocket +import java.io.IOException +import kotlin.random.Random + +/** + * Created by pedro on 8/04/21. + * + * The C0 and S0 packets are a single octet + * + * The version defined by this + * specification is 3. Values 0-2 are deprecated values used by + * earlier proprietary products; 4-31 are reserved for future + * implementations; and 32-255 are not allowed + * 0 1 2 3 4 5 6 7 + * +-+-+-+-+-+-+-+-+ + * | version | + * +-+-+-+-+-+-+-+-+ + * + * The C1 and S1 packets are 1536 octets long + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | time (4 bytes) | local generated timestamp + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | zero (4 bytes) | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | random bytes | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | random bytes | + * | (cont) | + * | .... | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * + * The C2 and S2 packets are 1536 octets long, and nearly an echo of S1 and C1 (respectively). + * + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | time (4 bytes) | s1 timestamp for c2 or c1 for s2. In this case s1 + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | time2 (4 bytes) | timestamp of previous packet (s1 or c1). In this case c1 + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | random echo | random data field sent by the peer in s1 for c2 or s2 for c1. + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | random echo | random data field sent by the peer in s1 for c2 or s2 for c1. + * | (cont) | + * | .... | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + */ +class Handshake { + + private val TAG = "Handshake" + + private val protocolVersion = 0x03 + private val handshakeSize = 1536 + private var timestampC1 = 0 + + @Throws(IOException::class) + suspend fun sendHandshake(socket: RtmpSocket): Boolean { + writeC0(socket) + val c1 = writeC1(socket) + socket.flush() + readS0(socket) + val s1 = readS1(socket) + writeC2(socket, s1) + socket.flush() + readS2(socket, c1) + return true + } + + @Throws(IOException::class) + private suspend fun writeC0(socket: RtmpSocket) { + Log.i(TAG, "writing C0") + socket.write(protocolVersion) + Log.i(TAG, "C0 write successful") + } + + @Throws(IOException::class) + private suspend fun writeC1(socket: RtmpSocket): ByteArray { + Log.i(TAG, "writing C1") + val c1 = ByteArray(handshakeSize) + + timestampC1 = (TimeUtils.getCurrentTimeMillis() / 1000).toInt() + Log.i(TAG, "writing time $timestampC1 to c1") + val timestampData = ByteArray(4) + timestampData[0] = (timestampC1 ushr 24).toByte() + timestampData[1] = (timestampC1 ushr 16).toByte() + timestampData[2] = (timestampC1 ushr 8).toByte() + timestampData[3] = timestampC1.toByte() + System.arraycopy(timestampData, 0, c1, 0, timestampData.size) + + Log.i(TAG, "writing zero to c1") + val zeroData = byteArrayOf(0x00, 0x00, 0x00, 0x00) + System.arraycopy(zeroData, 0, c1, timestampData.size, zeroData.size) + + Log.i(TAG, "writing random to c1") + val random = Random.Default + val randomData = ByteArray(handshakeSize - 8) + for (i in randomData.indices step 1) { //step UInt8 size + //random with UInt8 max value + val uInt8 = random.nextInt().toByte() + randomData[i] = uInt8 + } + System.arraycopy(randomData, 0, c1, 8, randomData.size) + socket.write(c1) + Log.i(TAG, "C1 write successful") + return c1 + } + + @Throws(IOException::class) + private suspend fun writeC2(socket: RtmpSocket, s1: ByteArray) { + Log.i(TAG, "writing C2") + socket.write(s1) + Log.i(TAG, "C2 write successful") + } + + @Throws(IOException::class) + private suspend fun readS0(socket: RtmpSocket): ByteArray { + Log.i(TAG, "reading S0") + val response = socket.read() + if (response == protocolVersion || response == 72) { + Log.i(TAG, "read S0 successful") + return byteArrayOf(response.toByte()) + } else { + throw IOException("$TAG error, unexpected $response S0 received") + } + } + + @Throws(IOException::class) + private suspend fun readS1(socket: RtmpSocket): ByteArray { + Log.i(TAG, "reading S1") + val s1 = ByteArray(handshakeSize) + socket.readUntil(s1) + Log.i(TAG, "read S1 successful") + return s1 + } + + @Throws(IOException::class) + private suspend fun readS2(socket: RtmpSocket, c1: ByteArray): ByteArray { + Log.i(TAG, "reading S2") + val s2 = ByteArray(handshakeSize) + socket.readUntil(s2) + //S2 should be equals to C1 but we can skip this + if (!s2.contentEquals(c1)) { + Log.e(TAG, "S2 content is different that C1") + } + Log.i(TAG, "read S2 successful") + return s2 + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/RtmpClient.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/RtmpClient.kt new file mode 100644 index 0000000..869efc4 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/RtmpClient.kt @@ -0,0 +1,614 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp + +import android.media.MediaCodec +// ScreenKit: upstream command/error logs may contain the stream key. +import com.pedro.common.StreamLog as Log +import com.pedro.common.AudioCodec +import com.pedro.common.ConnectChecker +import com.pedro.common.ConnectionFailed +import com.pedro.common.TimeUtils +import com.pedro.common.UrlParser +import com.pedro.common.VideoCodec +import com.pedro.common.clone +import com.pedro.common.frame.MediaFrame +import com.pedro.common.onMainThread +import com.pedro.common.toMediaFrameInfo +import com.pedro.common.validMessage +import com.pedro.rtmp.amf.AmfVersion +import com.pedro.rtmp.rtmp.message.* +import com.pedro.rtmp.rtmp.message.command.Command +import com.pedro.rtmp.rtmp.message.control.Type +import com.pedro.rtmp.rtmp.message.control.UserControl +import com.pedro.rtmp.utils.AuthUtil +import com.pedro.rtmp.utils.RtmpConfig +import com.pedro.rtmp.utils.socket.RtmpSocket +import com.pedro.rtmp.utils.socket.TcpSocket +import com.pedro.rtmp.utils.socket.TcpTunneledSocket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.io.* +import java.net.* +import java.nio.ByteBuffer +import javax.net.ssl.TrustManager + +/** + * Created by pedro on 8/04/21. + */ +class RtmpClient(private val connectChecker: ConnectChecker) { + + private val TAG = "RtmpClient" + + private val validSchemes = arrayOf("rtmp", "rtmps", "rtmpt", "rtmpts") + + private var socket: RtmpSocket? = null + private var scope = CoroutineScope(Dispatchers.IO) + private var scopeRetry = CoroutineScope(Dispatchers.IO) + private var job: Job? = null + private var jobRetry: Job? = null + private var commandsManager: CommandsManager = CommandsManagerAmf0() + private val rtmpSender = RtmpSender(connectChecker, commandsManager) + + @Volatile + var isStreaming = false + private set + + private var url: String? = null + private var tlsEnabled = false + private var certificates: TrustManager? = null + private var tunneled = false + + private var doingRetry = false + private var numRetry = 0 + private var reTries = 0 + private var checkServerAlive = false + private var publishPermitted = false + + val droppedAudioFrames: Long + get() = rtmpSender.droppedAudioFrames + val droppedVideoFrames: Long + get() = rtmpSender.droppedVideoFrames + + val cacheSize: Int + get() = rtmpSender.getCacheSize() + val sentAudioFrames: Long + get() = rtmpSender.getSentAudioFrames() + val sentVideoFrames: Long + get() = rtmpSender.getSentVideoFrames() + + /** + * Add certificates for TLS connection + */ + fun addCertificates(certificates: TrustManager?) { + this.certificates = certificates + } + + fun setVideoCodec(videoCodec: VideoCodec) { + if (!isStreaming) { + commandsManager.videoCodec = videoCodec + } + } + + fun setAudioCodec(audioCodec: AudioCodec) { + if (!isStreaming) { + commandsManager.audioCodec = when (audioCodec) { + AudioCodec.OPUS -> throw IllegalArgumentException("Unsupported codec: ${audioCodec.name}") + else -> audioCodec + } + } + } + + fun setAmfVersion(amfVersion: AmfVersion) { + if (!isStreaming) { + commandsManager = when (amfVersion) { + AmfVersion.VERSION_0 -> CommandsManagerAmf0() + AmfVersion.VERSION_3 -> CommandsManagerAmf3() + } + } + } + + /** + * Check periodically if server is alive using Echo protocol. + */ + fun setCheckServerAlive(enabled: Boolean) { + checkServerAlive = enabled + } + + /** + * Must be called before connect + */ + fun setOnlyAudio(onlyAudio: Boolean) { + commandsManager.audioDisabled = false + commandsManager.videoDisabled = onlyAudio + } + + /** + * Must be called before connect + */ + fun setOnlyVideo(onlyVideo: Boolean) { + commandsManager.videoDisabled = false + commandsManager.audioDisabled = onlyVideo + } + + fun forceIncrementalTs(enabled: Boolean) { + commandsManager.incrementalTs = enabled + } + + fun setWriteChunkSize(chunkSize: Int) { + if (!isStreaming) { + RtmpConfig.writeChunkSize = chunkSize + } + } + + fun setAuthorization(user: String?, password: String?) { + commandsManager.setAuth(user, password) + } + + fun setReTries(reTries: Int) { + numRetry = reTries + this.reTries = reTries + } + + fun shouldRetry(reason: String): Boolean { + val validReason = doingRetry && !reason.contains("Endpoint malformed") + return validReason && reTries > 0 + } + + fun setAudioInfo(sampleRate: Int, isStereo: Boolean) { + commandsManager.setAudioInfo(sampleRate, isStereo) + rtmpSender.setAudioInfo(sampleRate, isStereo) + } + + fun setVideoInfo(sps: ByteBuffer, pps: ByteBuffer?, vps: ByteBuffer?) { + Log.i(TAG, "send sps and pps") + rtmpSender.setVideoInfo(sps, pps, vps) + } + + fun setVideoResolution(width: Int, height: Int) { + commandsManager.setVideoResolution(width, height) + } + + fun setFps(fps: Int) { + commandsManager.fps = fps + } + + fun setFlashVersion(flashVersion: String) { + commandsManager.flashVersion = flashVersion + } + + fun connect(url: String?) { + connect(url, false) + } + + fun connect(url: String?, isRetry: Boolean) { + if (!isRetry) doingRetry = true + if (!isStreaming || isRetry) { + isStreaming = true + + job = scope.launch { + if (url == null) { + isStreaming = false + onMainThread { + connectChecker.onConnectionFailed( + "Endpoint malformed, should be: rtmp://ip:port/appname/streamname" + ) + } + return@launch + } + this@RtmpClient.url = url + onMainThread { + connectChecker.onConnectionStarted(url) + } + + val urlParser = try { + UrlParser.parse(url, validSchemes) + } catch (e: URISyntaxException) { + isStreaming = false + onMainThread { + connectChecker.onConnectionFailed( + "Endpoint malformed, should be: rtmp://ip:port/appname/streamname") + } + return@launch + } + + tunneled = urlParser.scheme.startsWith("rtmpt") + tlsEnabled = urlParser.scheme.endsWith("s") + commandsManager.host = urlParser.host + val defaultPort = if (tlsEnabled) 443 else if (tunneled) 80 else 1935 + commandsManager.port = urlParser.port ?: defaultPort + commandsManager.appName = urlParser.getAppName() + commandsManager.streamName = urlParser.getStreamName() + commandsManager.tcUrl = urlParser.getTcUrl() + if (commandsManager.appName.isEmpty()) { + isStreaming = false + onMainThread { + connectChecker.onConnectionFailed( + "Endpoint malformed, should be: rtmp://ip:port/appname/streamname") + } + return@launch + } + + val user = urlParser.getAuthUser() + val password = urlParser.getAuthPassword() + if (user != null && password != null) setAuthorization(user, password) + + val error = runCatching { + if (!establishConnection()) { + onMainThread { + connectChecker.onConnectionFailed("Handshake failed") + } + return@launch + } + val socket = this@RtmpClient.socket ?: throw IOException("Invalid socket, Connection failed") + commandsManager.sendChunkSize(socket) + commandsManager.sendConnect("", socket) + //read packets until you did success connection to server and you are ready to send packets + while (scope.isActive && !publishPermitted) { + //Handle all command received and send response for it. + handleMessages() + } + //read packet because maybe server want send you something while streaming + handleServerPackets() + }.exceptionOrNull() + if (error != null) { + Log.e(TAG, "connection error", error) + onMainThread { + connectChecker.onConnectionFailed("Error configure stream, ${error.validMessage()}") + } + return@launch + } + } + } + } + + private suspend fun handleServerPackets() { + while (scope.isActive && isStreaming) { + val error = runCatching { + if (isAlive()) { + delay(2000) + //ignore packet after connect if tunneled to avoid spam idle + if (!tunneled) handleMessages() + } else { + onMainThread { + connectChecker.onConnectionFailed("No response from server") + } + scope.cancel() + } + }.exceptionOrNull() + if (error != null && ConnectionFailed.parse(error.validMessage()) != ConnectionFailed.TIMEOUT) { + // ScreenKit: a read-side disconnect must reach the owning capture service. + onMainThread { connectChecker.onConnectionFailed("Streaming server disconnected") } + scope.cancel() + } + } + } + + /* + Send a heartbeat to know if server is alive using Echo Protocol. + Your firewall could block it. + */ + private fun isAlive(): Boolean { + val connected = socket?.isConnected() ?: false + if (!checkServerAlive) return connected + val reachable = socket?.isReachable() ?: false + return if (connected && !reachable) false else connected + } + + @Throws(IOException::class) + private suspend fun establishConnection(): Boolean { + val socket = if (tunneled) { + TcpTunneledSocket(commandsManager.host, commandsManager.port, tlsEnabled) + } else { + TcpSocket(commandsManager.host, commandsManager.port, tlsEnabled, certificates) + } + this.socket = socket + socket.connect() + if (!socket.isConnected()) return false + val timestamp = TimeUtils.getCurrentTimeMillis() / 1000 + val handshake = Handshake() + if (!handshake.sendHandshake(socket)) return false + commandsManager.timestamp = timestamp.toInt() + commandsManager.startTs = TimeUtils.getCurrentTimeNano() / 1000 + return true + } + + /** + * Read all messages from server and response to it + */ + @Throws(IOException::class) + private suspend fun handleMessages() { + var socket = this.socket ?: throw IOException("Invalid socket, Connection failed") + + val message = commandsManager.readMessageResponse(socket) + commandsManager.checkAndSendAcknowledgement(socket) + when (message.getType()) { + MessageType.SET_CHUNK_SIZE -> { + val setChunkSize = message as SetChunkSize + commandsManager.readChunkSize = setChunkSize.chunkSize + Log.i(TAG, "chunk size configured to ${setChunkSize.chunkSize}") + } + MessageType.ACKNOWLEDGEMENT -> { + val acknowledgement = message as Acknowledgement + } + MessageType.WINDOW_ACKNOWLEDGEMENT_SIZE -> { + val windowAcknowledgementSize = message as WindowAcknowledgementSize + RtmpConfig.acknowledgementWindowSize = windowAcknowledgementSize.acknowledgementWindowSize + } + MessageType.SET_PEER_BANDWIDTH -> { + val setPeerBandwidth = message as SetPeerBandwidth + commandsManager.sendWindowAcknowledgementSize(socket) + } + MessageType.ABORT -> { + val abort = message as Abort + } + MessageType.AGGREGATE -> { + val aggregate = message as Aggregate + } + MessageType.USER_CONTROL -> { + val userControl = message as UserControl + when (val type = userControl.type) { + Type.PING_REQUEST -> { + commandsManager.sendPong(userControl.event, socket) + } + else -> { + Log.i(TAG, "user control command $type ignored") + } + } + } + MessageType.COMMAND_AMF0, MessageType.COMMAND_AMF3 -> { + val command = message as Command + val commandName = commandsManager.sessionHistory.getName(command.commandId) + when (command.name) { + "_result" -> { + when (commandName) { + "connect" -> { + if (commandsManager.onAuth) { + onMainThread { + connectChecker.onAuthSuccess() + } + commandsManager.onAuth = false + } + commandsManager.createStream(socket) + } + "createStream" -> { + try { + commandsManager.streamId = command.getStreamId() + commandsManager.sendPublish(socket) + } catch (e: ClassCastException) { + Log.e(TAG, "error parsing _result createStream", e) + } + } + } + Log.i(TAG, "success response received from ${commandName ?: "unknown command"}") + } + "_error" -> { + try { + val description = command.getDescription() + when (commandName) { + "connect" -> { + if (description.contains("reason=authfail") || description.contains("reason=nosuchuser")) { + onMainThread { + connectChecker.onAuthError() + } + } else if (commandsManager.user != null && commandsManager.password != null && + description.contains("challenge=") && description.contains("salt=") //adobe response + || description.contains("nonce=")) { //llnw response + closeConnection() + establishConnection() + socket = this.socket ?: throw IOException("Invalid socket, Connection failed") + commandsManager.onAuth = true + if (description.contains("challenge=") && description.contains("salt=")) { //create adobe auth + val salt = AuthUtil.getSalt(description) + val challenge = AuthUtil.getChallenge(description) + val opaque = AuthUtil.getOpaque(description) + commandsManager.sendConnect(AuthUtil.getAdobeAuthUserResult(commandsManager.user + ?: "", commandsManager.password ?: "", + salt, challenge, opaque), socket) + } else if (description.contains("nonce=")) { //create llnw auth + val nonce = AuthUtil.getNonce(description) + commandsManager.sendConnect(AuthUtil.getLlnwAuthUserResult(commandsManager.user + ?: "", commandsManager.password ?: "", + nonce, commandsManager.appName), socket) + } + } else if (description.contains("code=403")) { + if (description.contains("authmod=adobe")) { + closeConnection() + establishConnection() + socket = this.socket ?: throw IOException("Invalid socket, Connection failed") + Log.i(TAG, "sending auth mode adobe") + commandsManager.sendConnect("?authmod=adobe&user=${commandsManager.user}", socket) + } else if (description.contains("authmod=llnw")) { + Log.i(TAG, "sending auth mode llnw") + commandsManager.sendConnect("?authmod=llnw&user=${commandsManager.user}", socket) + } + } else { + onMainThread { + connectChecker.onAuthError() + } + } + } + //We can ignore this errors. Some servers fail if this stream is not in use or don't implement this methods. + "releaseStream", "FCPublish" -> { + Log.e(TAG, "$commandName failed: $description") + } + else -> { + onMainThread { + connectChecker.onConnectionFailed(description) + } + } + } + } catch (e: ClassCastException) { + Log.e(TAG, "error parsing _error command", e) + } + } + "onStatus" -> { + try { + when (val code = command.getCode()) { + "NetStream.Publish.Start" -> { + commandsManager.sendMetadata(socket) + onMainThread { + connectChecker.onConnectionSuccess() + } + rtmpSender.socket = socket + rtmpSender.start() + publishPermitted = true + } + "NetConnection.Connect.Rejected", "NetStream.Publish.BadName" -> { + onMainThread { + connectChecker.onConnectionFailed("onStatus: $code") + } + } + else -> { + Log.i(TAG, "onStatus $code response received from ${commandName ?: "unknown command"}") + } + } + } catch (e: ClassCastException) { + Log.e(TAG, "error parsing onStatus command", e) + } + } + else -> { + Log.i(TAG, "unknown ${command.name} response received from ${commandName ?: "unknown command"}") + } + } + } + MessageType.VIDEO, MessageType.AUDIO, MessageType.DATA_AMF0, MessageType.DATA_AMF3, + MessageType.SHARED_OBJECT_AMF0, MessageType.SHARED_OBJECT_AMF3 -> { + Log.e(TAG, "unimplemented response for ${message.getType()}. Ignored") + } + } + } + + private suspend fun closeConnection() { + socket?.close() + commandsManager.reset() + } + + @JvmOverloads + fun reConnect(delay: Long, backupUrl: String? = null) { + jobRetry = scopeRetry.launch { + reTries-- + disconnect(false) + delay(delay) + val reconnectUrl = backupUrl ?: url + connect(reconnectUrl, true) + } + } + + fun disconnect() { + CoroutineScope(Dispatchers.IO).launch { + disconnect(true) + } + } + + private suspend fun disconnect(clear: Boolean) { + if (isStreaming) rtmpSender.stop(clear) + runCatching { + withTimeoutOrNull(100) { + socket?.let { commandsManager.sendClose(it) } + } + } + closeConnection() + if (clear) { + reTries = numRetry + doingRetry = false + isStreaming = false + onMainThread { + connectChecker.onDisconnect() + } + jobRetry?.cancelAndJoin() + jobRetry = null + scopeRetry.cancel() + scopeRetry = CoroutineScope(Dispatchers.IO) + } + job?.cancelAndJoin() + job = null + scope.cancel() + scope = CoroutineScope(Dispatchers.IO) + publishPermitted = false + commandsManager.reset() + } + + fun sendVideo(videoBuffer: ByteBuffer, info: MediaCodec.BufferInfo) { + if (!commandsManager.videoDisabled) { + rtmpSender.sendMediaFrame(MediaFrame(videoBuffer.clone(), info.toMediaFrameInfo(), MediaFrame.Type.VIDEO)) + } + } + + fun sendAudio(audioBuffer: ByteBuffer, info: MediaCodec.BufferInfo) { + if (!commandsManager.audioDisabled) { + rtmpSender.sendMediaFrame(MediaFrame(audioBuffer.clone(), info.toMediaFrameInfo(), MediaFrame.Type.AUDIO)) + } + } + + @JvmOverloads + @Throws(IllegalArgumentException::class) + fun hasCongestion(percentUsed: Float = 20f): Boolean { + return rtmpSender.hasCongestion(percentUsed) + } + + fun resetSentAudioFrames() { + rtmpSender.resetSentAudioFrames() + } + + fun resetSentVideoFrames() { + rtmpSender.resetSentVideoFrames() + } + + fun resetDroppedAudioFrames() { + rtmpSender.resetDroppedAudioFrames() + } + + fun resetDroppedVideoFrames() { + rtmpSender.resetDroppedVideoFrames() + } + + @Throws(RuntimeException::class) + fun resizeCache(newSize: Int) { + rtmpSender.resizeCache(newSize) + } + + fun setLogs(enable: Boolean) { + rtmpSender.setLogs(enable) + } + + fun clearCache() { + rtmpSender.clearCache() + } + + fun getItemsInCache(): Int = rtmpSender.getItemsInCache() + + /** + * @param factor values from 0.1f to 1f + * Set an exponential factor to the bitrate calculation to avoid bitrate spikes + */ + fun setBitrateExponentialFactor(factor: Float) { + rtmpSender.setBitrateExponentialFactor(factor) + } + + /** + * Get the exponential factor used to calculate the bitrate. Default 1f + */ + fun getBitrateExponentialFactor() = rtmpSender.getBitrateExponentialFactor() +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/RtmpSender.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/RtmpSender.kt new file mode 100644 index 0000000..25a2af9 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/RtmpSender.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp + +// ScreenKit: upstream command/error logs may contain the stream key. +import com.pedro.common.StreamLog as Log +import com.pedro.common.AudioCodec +import com.pedro.common.ConnectChecker +import com.pedro.common.VideoCodec +import com.pedro.common.base.BaseSender +import com.pedro.common.frame.MediaFrame +import com.pedro.common.onMainThread +import com.pedro.common.validMessage +import com.pedro.rtmp.flv.BasePacket +import com.pedro.rtmp.flv.FlvPacket +import com.pedro.rtmp.flv.FlvType +import com.pedro.rtmp.flv.audio.packet.AacPacket +import com.pedro.rtmp.flv.audio.packet.G711Packet +import com.pedro.rtmp.flv.video.packet.Av1Packet +import com.pedro.rtmp.flv.video.packet.H264Packet +import com.pedro.rtmp.flv.video.packet.H265Packet +import com.pedro.rtmp.utils.socket.RtmpSocket +import kotlinx.coroutines.isActive +import kotlinx.coroutines.runInterruptible +import java.nio.ByteBuffer +import java.util.concurrent.TimeUnit + +/** + * Created by pedro on 8/04/21. + */ +class RtmpSender( + connectChecker: ConnectChecker, + private val commandsManager: CommandsManager +): BaseSender(connectChecker, "RtmpSender") { + + private var audioPacket: BasePacket = AacPacket() + private var videoPacket: BasePacket = H264Packet() + var socket: RtmpSocket? = null + + override fun setVideoInfo(sps: ByteBuffer, pps: ByteBuffer?, vps: ByteBuffer?) { + videoPacket = when (commandsManager.videoCodec) { + VideoCodec.H265 -> { + if (vps == null || pps == null) throw IllegalArgumentException("pps or vps can't be null with h265") + H265Packet().apply { sendVideoInfo(sps, pps, vps) } + } + VideoCodec.AV1 -> { + Av1Packet().apply { sendVideoInfo(sps) } + } + else -> { + if (pps == null) throw IllegalArgumentException("pps can't be null with h264") + H264Packet().apply { sendVideoInfo(sps, pps) } + } + } + } + + override fun setAudioInfo(sampleRate: Int, isStereo: Boolean) { + audioPacket = when (commandsManager.audioCodec) { + AudioCodec.G711 -> G711Packet().apply { sendAudioInfo() } + AudioCodec.AAC -> AacPacket().apply { sendAudioInfo(sampleRate, isStereo) } + AudioCodec.OPUS -> { + throw IllegalArgumentException("Unsupported codec: ${commandsManager.audioCodec.name}") + } + } + } + + override suspend fun onRun() { + while (scope.isActive && running) { + val error = runCatching { + val mediaFrame = runInterruptible { queue.poll(1, TimeUnit.SECONDS) } + getFlvPacket(mediaFrame) { flvPacket -> + var size = 0 + if (flvPacket.type == FlvType.VIDEO) { + videoFramesSent++ + socket?.let { socket -> + size = commandsManager.sendVideoPacket(flvPacket, socket) + if (isEnableLogs) { + Log.i(TAG, "wrote Video packet, size $size") + } + } + } else { + audioFramesSent++ + socket?.let { socket -> + size = commandsManager.sendAudioPacket(flvPacket, socket) + if (isEnableLogs) { + Log.i(TAG, "wrote Audio packet, size $size") + } + } + } + bytesSend += size + } + }.exceptionOrNull() + if (error != null) { + onMainThread { + connectChecker.onConnectionFailed("Error send packet, ${error.validMessage()}") + } + Log.e(TAG, "send error: ", error) + running = false + return + } + } + } + + override suspend fun stopImp(clear: Boolean) { + audioPacket.reset(clear) + videoPacket.reset(clear) + } + + private suspend fun getFlvPacket(mediaFrame: MediaFrame?, callback: suspend (FlvPacket) -> Unit) { + if (mediaFrame == null) return + when (mediaFrame.type) { + MediaFrame.Type.VIDEO -> videoPacket.createFlvPacket(mediaFrame) { callback(it) } + MediaFrame.Type.AUDIO -> audioPacket.createFlvPacket(mediaFrame) { callback(it) } + } + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/chunk/ChunkStreamId.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/chunk/ChunkStreamId.kt new file mode 100644 index 0000000..6c7feac --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/chunk/ChunkStreamId.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.chunk + +/** + * Created by pedro on 21/04/21. + */ +enum class ChunkStreamId(val mark: Int) { + PROTOCOL_CONTROL(0x02), + OVER_CONNECTION(0x03), + OVER_CONNECTION2(0x04), + OVER_STREAM(0x05), + VIDEO(0x06), + AUDIO(0x07) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/chunk/ChunkType.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/chunk/ChunkType.kt new file mode 100644 index 0000000..3e14955 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/chunk/ChunkType.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.chunk + +/** + * Created by pedro on 21/04/21. + * + * timestamp = 3 bytes (timestamp of packet. If is greater than or + * equal to 16777215 (hexadecimal 0xFFFFFF), this field MUST be 16777215) + * + * message length = 3 bytes (packet size) + * + * message type id = 1 byte (represented by MessageType class) + * + * message stream id = 4 bytes (transactionId as little endian format) + */ +enum class ChunkType(val mark: Byte) { + + /** + * 11 bytes long. + * This type MUST be used at the start of a chunk stream, and whenever the stream timestamp goes + * backward (e.g., because of a backward seek). + * + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | timestamp |message length | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | message length (cont) |message type id| msg stream id | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | message stream id (cont) | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + */ + TYPE_0(0x00), + + /** + * 7 bytes long. + * The message stream ID is not included; this chunk takes the same stream ID as the preceding chunk. + * Streams with variable-sized messages (for example, many video formats) + * SHOULD use this format for the first chunk of each new message after the first + * + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | timestamp delta |message length | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | message length (cont) |message type id| + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + */ + TYPE_1(0x01), + + /** + * 3 bytes long. + * Neither the stream ID nor the + * message length is included; this chunk has the same stream ID and + * message length as the preceding chunk. Streams with constant-sized + * messages (for example, some audio and data formats) SHOULD use this + * format for the first chunk of each message after the first. + * + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | timestamp delta | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + */ + TYPE_2(0x02), + + /** + * Have no message header + * When a single message is split into chunks, all chunks of a message except + * the first one SHOULD use this type + */ + TYPE_3(0x03) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Abort.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Abort.kt new file mode 100644 index 0000000..5c0fdf6 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Abort.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message + +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.utils.readUInt32 +import com.pedro.rtmp.utils.writeUInt32 +import java.io.ByteArrayOutputStream +import java.io.InputStream + +/** + * Created by pedro on 21/04/21. + */ +class Abort(private var chunkStreamId: Int = 0): + RtmpMessage(BasicHeader(ChunkType.TYPE_0, ChunkStreamId.PROTOCOL_CONTROL.mark)) { + + override fun readBody(input: InputStream) { + chunkStreamId = input.readUInt32() + } + + override fun storeBody(): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + byteArrayOutputStream.writeUInt32(chunkStreamId) + return byteArrayOutputStream.toByteArray() + } + + override fun getType(): MessageType = MessageType.ABORT + + override fun getSize(): Int = 4 + + override fun toString(): String { + return "Abort(chunkStreamId=$chunkStreamId)" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Acknowledgement.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Acknowledgement.kt new file mode 100644 index 0000000..10acdf6 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Acknowledgement.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message + +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.utils.readUInt32 +import com.pedro.rtmp.utils.writeUInt32 +import java.io.ByteArrayOutputStream +import java.io.InputStream + +/** + * Created by pedro on 21/04/21. + */ +class Acknowledgement(private var sequenceNumber: Int = 0): + RtmpMessage(BasicHeader(ChunkType.TYPE_0, ChunkStreamId.PROTOCOL_CONTROL.mark)) { + + override fun readBody(input: InputStream) { + sequenceNumber = input.readUInt32() + } + + override fun storeBody(): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + byteArrayOutputStream.writeUInt32(sequenceNumber) + return byteArrayOutputStream.toByteArray() + } + + override fun getType(): MessageType = MessageType.ACKNOWLEDGEMENT + + override fun getSize(): Int = 4 + + override fun toString(): String { + return "Acknowledgement(sequenceNumber=$sequenceNumber)" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Aggregate.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Aggregate.kt new file mode 100644 index 0000000..d4fd63e --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Aggregate.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message + +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import java.io.InputStream + +/** + * Created by pedro on 21/04/21. + */ +class Aggregate: RtmpMessage(BasicHeader(ChunkType.TYPE_0, ChunkStreamId.PROTOCOL_CONTROL.mark)) { + + override fun readBody(input: InputStream) { + TODO("Not yet implemented") + } + + override fun storeBody(): ByteArray { + TODO("Not yet implemented") + } + + override fun getType(): MessageType = MessageType.AGGREGATE + + override fun getSize(): Int { + TODO("Not yet implemented") + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Audio.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Audio.kt new file mode 100644 index 0000000..90ac8cd --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Audio.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message + +import com.pedro.rtmp.flv.FlvPacket +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import java.io.InputStream + +/** + * Created by pedro on 21/04/21. + */ +class Audio(private val flvPacket: FlvPacket = FlvPacket(), streamId: Int = 0): RtmpMessage(BasicHeader(ChunkType.TYPE_0, ChunkStreamId.AUDIO.mark)) { + + init { + header.messageStreamId = streamId + header.timeStamp = flvPacket.timeStamp.toInt() + header.messageLength = flvPacket.length + } + + override fun readBody(input: InputStream) { + } + + override fun storeBody(): ByteArray = flvPacket.buffer + + override fun getType(): MessageType = MessageType.AUDIO + + override fun getSize(): Int = flvPacket.length + + override fun toString(): String { + return "Audio, size: ${getSize()}" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/BasicHeader.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/BasicHeader.kt new file mode 100644 index 0000000..383438b --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/BasicHeader.kt @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message + +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.utils.socket.RtmpSocket +import java.io.IOException +import kotlin.experimental.and + +/** + * Created by pedro on 21/04/21. + * + * cs id (6 bits) + * fmt (2 bits) + * cs id - 64 (8 or 16 bits) + * + * 0 1 2 3 4 5 6 7 + * +-+-+-+-+-+-+-+-+ + * |fmt| cs id | + * +-+-+-+-+-+-+-+-+ + * Chunk basic header 1 + * + * + * Chunk stream IDs 64-319 can be encoded in the 2-byte form of the + * header. ID is computed as (the second byte + 64). + * + * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * |fmt| 0 | cs id - 64 | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * Chunk basic header 2 + * + * + * Chunk stream IDs 64-65599 can be encoded in the 3-byte version of + * this field. ID is computed as ((the third byte)*256 + (the second + * byte) + 64). + * + * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * |fmt| 1 | cs id - 64 | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * + * Chunk basic header 3 + */ +class BasicHeader(val chunkType: ChunkType, val chunkStreamId: Int) { + + companion object { + suspend fun parseBasicHeader(socket: RtmpSocket): BasicHeader { + val byte = socket.read().toByte() + val chunkTypeValue = 0xff and byte.toInt() ushr 6 + val chunkType = ChunkType.entries.find { it.mark.toInt() == chunkTypeValue } ?: throw IOException("Unknown chunk type value: $chunkTypeValue") + var chunkStreamIdValue = (byte and 0x3F).toInt() + if (chunkStreamIdValue > 63) throw IOException("Unknown chunk stream id value: $chunkStreamIdValue") + if (chunkStreamIdValue == 0) { //Basic header 2 bytes + chunkStreamIdValue = socket.read() - 64 + } else if (chunkStreamIdValue == 1) { //Basic header 3 bytes + val a = socket.read() + val b = socket.read() + val value = b and 0xff shl 8 and a + chunkStreamIdValue = value - 64 + } + return BasicHeader(chunkType, chunkStreamIdValue) + } + } + + fun getHeaderSize(timestamp: Int): Int { + var size = when (chunkType) { + ChunkType.TYPE_0 -> 12 + ChunkType.TYPE_1 -> 8 + ChunkType.TYPE_2 -> 4 + ChunkType.TYPE_3 -> 0 + } + if (timestamp >= 0xffffff) { + size += 4 + } + return size + } + + override fun toString(): String { + return "BasicHeader chunkType: $chunkType, chunkStreamId: $chunkStreamId" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/MessageType.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/MessageType.kt new file mode 100644 index 0000000..95a2157 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/MessageType.kt @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message + + +/** + * Created by pedro on 21/04/21. + */ +enum class MessageType(val mark: Byte) { + + /** + * Set Chunk Size, is used to notify the peer a new maximum chunk size to use. + */ + SET_CHUNK_SIZE(0x01), + + /** + * Abort Message, is used to notify the peer if it is waiting for chunks + * to complete a message, then to discard the partially received message + * over a chunk stream and abort processing of that message. + */ + ABORT(0x02), + + /** + * The client or the server sends the acknowledgment to the peer after + * receiving bytes equal to the window size. The window size is the + * maximum number of bytes that the sender sends without receiving + * acknowledgment from the receiver. + */ + ACKNOWLEDGEMENT(0x03), + + /** + * The client or the server sends this message to notify the peer about + * the user control events. This message carries Event type and Event + * data. + * Also known as a PING message in some RTMP implementations. + */ + USER_CONTROL(0x04), + + /** + * The client or the server sends this message to inform the peer which + * window size to use when sending acknowledgment. + * Also known as ServerBW ("server bandwidth") in some RTMP implementations. + */ + WINDOW_ACKNOWLEDGEMENT_SIZE(0x05), + + /** + * The client or the server sends this message to update the output + * bandwidth of the peer. The output bandwidth value is the same as the + * window size for the peer. + * Also known as ClientBW ("client bandwidth") in some RTMP implementations. + */ + SET_PEER_BANDWIDTH(0x06), + + /** + * RTMP audio packet (0x08) + * The client or the server sends this message to send audio data to the peer. + */ + AUDIO(0x08), + + /** + * RTMP video packet (0x09) + * The client or the server sends this message to send video data to the peer. + */ + VIDEO(0x09), + + /** + * The client or the server sends this message to send Metadata or any + * user data to the peer. Metadata includes details about the data (audio, video etc.) + * like creation time, duration, theme and so on. + * This is the AMF3-encoded version. + */ + DATA_AMF3(0x0F), + + /** + * A shared object is a Flash object (a collection of name value pairs) + * that are in synchronization across multiple clients, instances, and + * so on. + * This is the AMF3 version: kMsgContainerEx=16 for AMF3. + */ + SHARED_OBJECT_AMF3(0x10), + + /** + * Command messages carry the AMF-encoded commands between the client + * and the server. + * A command message consists of command name, transaction ID, and command object that + * contains related parameters. + * This is the AMF3-encoded version. + */ + COMMAND_AMF3(0x11), + + /** + * The client or the server sends this message to send Metadata or any + * user data to the peer. Metadata includes details about the data (audio, video etc.) + * like creation time, duration, theme and so on. + * This is the AMF0-encoded version. + */ + DATA_AMF0(0x012), + + /** + * A shared object is a Flash object (a collection of name value pairs) + * that are in synchronization across multiple clients, instances, and + * so on. + * This is the AMF0 version: kMsgContainer=19 for AMF0. + */ + SHARED_OBJECT_AMF0(0x13), + + /** + * Command messages carry the AMF-encoded commands between the client + * and the server. + * A command message consists of command name, transaction ID, and command object that + * contains related parameters. + * This is the common AMF0 version, also known as INVOKE in some RTMP implementations. + */ + COMMAND_AMF0(0x14), + + /** + * An aggregate message is a single message that contains a list of sub-messages. + */ + AGGREGATE(0x16) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/RtmpHeader.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/RtmpHeader.kt new file mode 100644 index 0000000..aa98376 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/RtmpHeader.kt @@ -0,0 +1,165 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message + +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.utils.* +import com.pedro.rtmp.utils.socket.RtmpSocket +import java.io.IOException +import kotlin.math.min + +/** + * Created by pedro on 20/04/21. + */ +class RtmpHeader(var basicHeader: BasicHeader) { + + var timeStamp: Int = 0 + var messageLength: Int = 0 + var messageType: MessageType? = null + var messageStreamId: Int = 0 + + companion object { + + private const val TAG = "RtmpHeader" + + /** + * Check ChunkType class to know header structure + */ + @Throws(IOException::class) + suspend fun readHeader(socket: RtmpSocket, commandSessionHistory: CommandSessionHistory, + timestamp: Int = 0): RtmpHeader { + val basicHeader = BasicHeader.parseBasicHeader(socket) + var timeStamp = timestamp + var messageLength = 0 + var messageType: MessageType? = null + var messageStreamId = 0 + val lastHeader = commandSessionHistory.getLastReadHeader(basicHeader.chunkStreamId) + when (basicHeader.chunkType) { + ChunkType.TYPE_0 -> { + timeStamp = socket.readUInt24() + messageLength = socket.readUInt24() + messageType = RtmpMessage.getMarkType(socket.read()) + messageStreamId = socket.readUInt32LittleEndian() + //extended timestamp + if (timeStamp >= 0xffffff) { + timeStamp = socket.readUInt32() + } + } + ChunkType.TYPE_1 -> { + if (lastHeader != null) { + messageStreamId = lastHeader.messageStreamId + } + timeStamp = socket.readUInt24() + messageLength = socket.readUInt24() + messageType = RtmpMessage.getMarkType(socket.read()) + //extended timestamp + if (timeStamp >= 0xffffff) { + timeStamp = socket.readUInt32() + } + } + ChunkType.TYPE_2 -> { + if (lastHeader != null) { + messageLength = lastHeader.messageLength + messageType = lastHeader.messageType + messageStreamId = lastHeader.messageStreamId + } + timeStamp = socket.readUInt24() + //extended timestamp + if (timeStamp >= 0xffffff) { + timeStamp = socket.readUInt32() + } + } + ChunkType.TYPE_3 -> { + if (lastHeader != null) { + timeStamp = lastHeader.timeStamp + messageLength = lastHeader.messageLength + messageType = lastHeader.messageType + messageStreamId = lastHeader.messageStreamId + } + //extended timestamp + if (timeStamp >= 0xffffff) { + timeStamp = socket.readUInt32() + } + //No header to read + } + } + val rtmpHeader = RtmpHeader(basicHeader) + rtmpHeader.timeStamp = timeStamp + rtmpHeader.messageLength = messageLength + rtmpHeader.messageType = messageType + rtmpHeader.messageStreamId = messageStreamId + return rtmpHeader + } + } + + @Throws(IOException::class) + suspend fun writeHeader(socket: RtmpSocket) { + writeHeader(basicHeader, socket) + } + + /** + * Check ChunkType class to know header structure + */ + suspend fun writeHeader(basicHeader: BasicHeader, socket: RtmpSocket) { + // Write basic header byte + socket.write((basicHeader.chunkType.mark.toInt() shl 6) or basicHeader.chunkStreamId) + when (basicHeader.chunkType) { + ChunkType.TYPE_0 -> { + socket.writeUInt24(min(timeStamp, 0xffffff)) + socket.writeUInt24(messageLength) + messageType?.let { messageType -> + socket.write(messageType.mark.toInt()) + } + socket.writeUInt32LittleEndian(messageStreamId) + //extended timestamp + if (timeStamp > 0xffffff) { + socket.writeUInt32(timeStamp) + } + } + ChunkType.TYPE_1 -> { + socket.writeUInt24(min(timeStamp, 0xffffff)) + socket.writeUInt24(messageLength) + messageType?.let { messageType -> + socket.write(messageType.mark.toInt()) + } + //extended timestamp + if (timeStamp > 0xffffff) { + socket.writeUInt32(timeStamp) + } + } + ChunkType.TYPE_2 -> { + socket.writeUInt24(min(timeStamp, 0xffffff)) + //extended timestamp + if (timeStamp > 0xffffff) { + socket.writeUInt32(timeStamp) + } + } + ChunkType.TYPE_3 -> { + //extended timestamp + if (timeStamp > 0xffffff) { + socket.writeUInt32(timeStamp) + } + } + } + } + + fun getPacketLength(): Int = messageLength + basicHeader.getHeaderSize(timeStamp) + + override fun toString(): String { + return "RtmpHeader(timeStamp=$timeStamp, messageLength=$messageLength, messageType=$messageType, messageStreamId=$messageStreamId, basicHeader=$basicHeader)" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/RtmpMessage.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/RtmpMessage.kt new file mode 100644 index 0000000..d098394 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/RtmpMessage.kt @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message + +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.rtmp.message.command.CommandAmf0 +import com.pedro.rtmp.rtmp.message.command.CommandAmf3 +import com.pedro.rtmp.rtmp.message.control.UserControl +import com.pedro.rtmp.rtmp.message.data.DataAmf0 +import com.pedro.rtmp.rtmp.message.data.DataAmf3 +import com.pedro.rtmp.rtmp.message.shared.SharedObjectAmf0 +import com.pedro.rtmp.rtmp.message.shared.SharedObjectAmf3 +import com.pedro.rtmp.utils.CommandSessionHistory +import com.pedro.rtmp.utils.RtmpConfig +import com.pedro.rtmp.utils.socket.RtmpSocket +import java.io.* + +/** + * Created by pedro on 20/04/21. + */ +abstract class RtmpMessage(basicHeader: BasicHeader) { + + val header by lazy { + RtmpHeader(basicHeader).apply { + messageType = getType() + messageLength = getSize() + } + } + + companion object { + + private const val TAG = "RtmpMessage" + + @Throws(IOException::class) + suspend fun getRtmpMessage(socket: RtmpSocket, chunkSize: Int, + commandSessionHistory: CommandSessionHistory): RtmpMessage { + val header = RtmpHeader.readHeader(socket, commandSessionHistory) + val rtmpMessage = when (header.messageType) { + MessageType.SET_CHUNK_SIZE -> SetChunkSize() + MessageType.ABORT -> Abort() + MessageType.ACKNOWLEDGEMENT -> Acknowledgement() + MessageType.USER_CONTROL -> UserControl() + MessageType.WINDOW_ACKNOWLEDGEMENT_SIZE -> WindowAcknowledgementSize() + MessageType.SET_PEER_BANDWIDTH -> SetPeerBandwidth() + MessageType.AUDIO -> Audio() + MessageType.VIDEO -> Video() + MessageType.DATA_AMF3 -> DataAmf3() + MessageType.SHARED_OBJECT_AMF3 -> SharedObjectAmf3() + MessageType.COMMAND_AMF3 -> CommandAmf3() + MessageType.DATA_AMF0 -> DataAmf0() + MessageType.SHARED_OBJECT_AMF0 -> SharedObjectAmf0() + MessageType.COMMAND_AMF0 -> CommandAmf0() + MessageType.AGGREGATE -> Aggregate() + else -> throw IOException("Unimplemented message type: ${header.messageType}") + } + rtmpMessage.updateHeader(header) + //we have multiple chunk wait until we have full body on stream and discard chunk header + val bodyInput = if (header.messageLength > chunkSize) { + getInputWithoutChunks(socket, header, chunkSize, commandSessionHistory) + } else { + val bytes = ByteArray(header.messageLength) + socket.readUntil(bytes) + ByteArrayInputStream(bytes) + } + rtmpMessage.readBody(bodyInput) + return rtmpMessage + } + + fun getMarkType(type: Int): MessageType { + return MessageType.entries.find { it.mark.toInt() == type } ?: throw IOException("Unknown rtmp message type: $type") + } + + private suspend fun getInputWithoutChunks(socket: RtmpSocket, header: RtmpHeader, chunkSize: Int, + commandSessionHistory: CommandSessionHistory): InputStream { + val packetStore = ByteArrayOutputStream() + var bytesRead = 0 + while (bytesRead < header.messageLength) { + var chunk: ByteArray + if (header.messageLength - bytesRead < chunkSize) { + //last chunk + chunk = ByteArray(header.messageLength - bytesRead) + socket.readUntil(chunk) + } else { + chunk = ByteArray(chunkSize) + socket.readUntil(chunk) + //skip chunk header to discard it, set packet ts to indicate if you need read extended ts + RtmpHeader.readHeader(socket, commandSessionHistory, header.timeStamp) + } + bytesRead += chunk.size + packetStore.write(chunk) + } + return ByteArrayInputStream(packetStore.toByteArray()) + } + } + + fun updateHeader(rtmpHeader: RtmpHeader) { + header.basicHeader = rtmpHeader.basicHeader + header.messageType = rtmpHeader.messageType + header.messageLength = rtmpHeader.messageLength + header.messageStreamId = rtmpHeader.messageStreamId + header.timeStamp = rtmpHeader.timeStamp + } + + suspend fun writeHeader(socket: RtmpSocket) { + header.writeHeader(socket) + } + + suspend fun writeBody(socket: RtmpSocket) { + val chunkSize = RtmpConfig.writeChunkSize + val bytes = storeBody() + var pos = 0 + var length = getSize() + + while (length > chunkSize) { + // Write packet for chunk + socket.write(bytes, pos, chunkSize) + length -= chunkSize + pos += chunkSize + // Write header for remain chunk + header.writeHeader(BasicHeader(ChunkType.TYPE_3, header.basicHeader.chunkStreamId), socket) + } + socket.write(bytes, pos, length) + } + + abstract fun readBody(input: InputStream) + + abstract fun storeBody(): ByteArray + + abstract fun getType(): MessageType + + abstract fun getSize(): Int +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/SetChunkSize.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/SetChunkSize.kt new file mode 100644 index 0000000..c4904cf --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/SetChunkSize.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message + +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.utils.RtmpConfig +import com.pedro.rtmp.utils.readUInt32 +import com.pedro.rtmp.utils.writeUInt32 +import java.io.ByteArrayOutputStream +import java.io.InputStream + +/** + * Created by pedro on 21/04/21. + */ +class SetChunkSize(var chunkSize: Int = RtmpConfig.DEFAULT_CHUNK_SIZE): + RtmpMessage(BasicHeader(ChunkType.TYPE_0, ChunkStreamId.PROTOCOL_CONTROL.mark)) { + + override fun readBody(input: InputStream) { + chunkSize = input.readUInt32() + } + + override fun storeBody(): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + byteArrayOutputStream.writeUInt32(chunkSize) + return byteArrayOutputStream.toByteArray() + } + + override fun getType(): MessageType = MessageType.SET_CHUNK_SIZE + + override fun getSize(): Int = 4 + + override fun toString(): String { + return "SetChunkSize(chunkSize=$chunkSize)" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/SetPeerBandwidth.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/SetPeerBandwidth.kt new file mode 100644 index 0000000..21ba6ce --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/SetPeerBandwidth.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message + +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.utils.readUInt32 +import com.pedro.rtmp.utils.writeUInt32 +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream + +/** + * Created by pedro on 21/04/21. + */ +class SetPeerBandwidth(private var acknowledgementWindowSize: Int = 0, private var type: Type = Type.DYNAMIC): + RtmpMessage(BasicHeader(ChunkType.TYPE_0, ChunkStreamId.PROTOCOL_CONTROL.mark)) { + + enum class Type(val mark: Byte) { + HARD(0x00), SOFT(0x01), DYNAMIC(0x02) + } + + override fun readBody(input: InputStream) { + acknowledgementWindowSize = input.readUInt32() + val t = input.read().toByte() + type = Type.entries.find { it.mark == t } ?: throw IOException("Unknown bandwidth type: $t") + } + + override fun storeBody(): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + byteArrayOutputStream.writeUInt32(acknowledgementWindowSize) + byteArrayOutputStream.write(type.mark.toInt()) + return byteArrayOutputStream.toByteArray() + } + + override fun getType(): MessageType = MessageType.SET_PEER_BANDWIDTH + + override fun getSize(): Int = 5 + + override fun toString(): String { + return "SetPeerBandwidth(acknowledgementWindowSize=$acknowledgementWindowSize, type=$type)" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Video.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Video.kt new file mode 100644 index 0000000..ab94d42 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/Video.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message + +import com.pedro.rtmp.flv.FlvPacket +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import java.io.InputStream + +/** + * Created by pedro on 21/04/21. + */ +class Video(private val flvPacket: FlvPacket = FlvPacket(), streamId: Int = 0): RtmpMessage(BasicHeader(ChunkType.TYPE_0, ChunkStreamId.VIDEO.mark)) { + + init { + header.messageStreamId = streamId + header.timeStamp = flvPacket.timeStamp.toInt() + header.messageLength = flvPacket.length + } + + override fun readBody(input: InputStream) { + } + + override fun storeBody(): ByteArray = flvPacket.buffer + + override fun getType(): MessageType = MessageType.VIDEO + + override fun getSize(): Int = flvPacket.length + + override fun toString(): String { + return "Video, size: ${getSize()}" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/WindowAcknowledgementSize.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/WindowAcknowledgementSize.kt new file mode 100644 index 0000000..f22aec2 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/WindowAcknowledgementSize.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message + +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.utils.readUInt32 +import com.pedro.rtmp.utils.writeUInt32 +import java.io.ByteArrayOutputStream +import java.io.InputStream + +/** + * Created by pedro on 21/04/21. + */ +class WindowAcknowledgementSize(var acknowledgementWindowSize: Int = 0, timeStamp: Int = 0): + RtmpMessage(BasicHeader(ChunkType.TYPE_0, ChunkStreamId.PROTOCOL_CONTROL.mark)) { + + init { + header.timeStamp = timeStamp + } + + override fun readBody(input: InputStream) { + acknowledgementWindowSize = input.readUInt32() + } + + override fun storeBody(): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + byteArrayOutputStream.writeUInt32(acknowledgementWindowSize) + return byteArrayOutputStream.toByteArray() + } + + override fun getType(): MessageType = MessageType.WINDOW_ACKNOWLEDGEMENT_SIZE + + override fun getSize(): Int = 4 + + override fun toString(): String { + return "WindowAcknowledgementSize(acknowledgementWindowSize=$acknowledgementWindowSize)" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/command/Command.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/command/Command.kt new file mode 100644 index 0000000..71d795a --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/command/Command.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message.command + +import com.pedro.rtmp.rtmp.message.BasicHeader +import com.pedro.rtmp.rtmp.message.RtmpMessage + +/** + * Created by pedro on 8/04/21. + * + * Represent packets like connect, createStream, play, pause... + * Can be encoded using AMF0 or AMF3 + * + * TODO use amf3 or amf0 depend of getType method + */ +abstract class Command(var name: String = "", var commandId: Int, private val timeStamp: Int, private val streamId: Int = 0, basicHeader: BasicHeader): RtmpMessage(basicHeader) { + + protected var bodySize = 0 + + init { + header.messageLength = bodySize + header.timeStamp = timeStamp + header.messageStreamId = streamId + } + + abstract fun getStreamId(): Int + abstract fun getDescription(): String + abstract fun getCode(): String + + override fun getSize(): Int = bodySize +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/command/CommandAmf0.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/command/CommandAmf0.kt new file mode 100644 index 0000000..36a258e --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/command/CommandAmf0.kt @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message.command + +import com.pedro.rtmp.amf.v0.AmfData +import com.pedro.rtmp.amf.v0.AmfNumber +import com.pedro.rtmp.amf.v0.AmfObject +import com.pedro.rtmp.amf.v0.AmfString +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.rtmp.message.BasicHeader +import com.pedro.rtmp.rtmp.message.MessageType +import java.io.ByteArrayOutputStream +import java.io.InputStream + +/** + * Created by pedro on 21/04/21. + */ +class CommandAmf0(name: String = "", commandId: Int = 0, private val timestamp: Int = 0, private val streamId: Int = 0, basicHeader: BasicHeader = + BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_CONNECTION.mark)): Command(name, commandId, timestamp, streamId, basicHeader = basicHeader) { + + private val data: MutableList = mutableListOf() + + init { + val amfString = AmfString(name) + data.add(amfString) + bodySize += amfString.getSize() + 1 + val amfNumber = AmfNumber(commandId.toDouble()) + bodySize += amfNumber.getSize() + 1 + data.add(amfNumber) + } + + fun addData(amfData: AmfData) { + data.add(amfData) + bodySize += amfData.getSize() + 1 + header.messageLength = bodySize + } + + override fun getStreamId(): Int { + return (data[3] as AmfNumber).value.toInt() + } + + override fun getDescription(): String { + return ((data[3] as AmfObject).getProperty("description") as AmfString).value + } + + override fun getCode(): String { + return ((data[3] as AmfObject).getProperty("code") as AmfString).value + } + + override fun readBody(input: InputStream) { + data.clear() + var bytesRead = 0 + while (bytesRead < header.messageLength) { + val amfData = AmfData.getAmfData(input) + bytesRead += amfData.getSize() + 1 + data.add(amfData) + } + if (data.isNotEmpty()) { + if (data[0] is AmfString) { + name = (data[0] as AmfString).value + } + if (data.size >= 2 && data[1] is AmfNumber) { + commandId = (data[1] as AmfNumber).value.toInt() + } + } + bodySize = bytesRead + header.messageLength = bodySize + } + + override fun storeBody(): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + data.forEach { + it.writeHeader(byteArrayOutputStream) + it.writeBody(byteArrayOutputStream) + } + return byteArrayOutputStream.toByteArray() + } + + override fun getType(): MessageType = MessageType.COMMAND_AMF0 + + override fun toString(): String { + return "Command(name='$name', transactionId=$commandId, timeStamp=$timestamp, streamId=$streamId, data=$data, bodySize=$bodySize)" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/command/CommandAmf3.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/command/CommandAmf3.kt new file mode 100644 index 0000000..6dbc439 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/command/CommandAmf3.kt @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message.command + +import com.pedro.rtmp.amf.v3.Amf3Data +import com.pedro.rtmp.amf.v3.Amf3Double +import com.pedro.rtmp.amf.v3.Amf3Object +import com.pedro.rtmp.amf.v3.Amf3String +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.rtmp.message.BasicHeader +import com.pedro.rtmp.rtmp.message.MessageType +import java.io.ByteArrayOutputStream +import java.io.InputStream + +/** + * Created by pedro on 21/04/21. + */ +class CommandAmf3(name: String = "", commandId: Int = 0, private val timestamp: Int = 0, private val streamId: Int = 0, basicHeader: BasicHeader = + BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_CONNECTION.mark)): Command(name, commandId, timestamp, streamId, basicHeader = basicHeader) { + + private val data: MutableList = mutableListOf() + + init { + val amf3String = Amf3String(name) + data.add(amf3String) + bodySize += amf3String.getSize() + 1 + val amf3Double = Amf3Double(commandId.toDouble()) + bodySize += amf3Double.getSize() + 1 + data.add(amf3Double) + } + + fun addData(amf3Data: Amf3Data) { + data.add(amf3Data) + bodySize += amf3Data.getSize() + 1 + header.messageLength = bodySize + } + + override fun getStreamId(): Int { + return (data[3] as Amf3Double).value.toInt() + } + + override fun getDescription(): String { + return ((data[3] as Amf3Object).getProperty("description") as Amf3String).value + } + + override fun getCode(): String { + return ((data[3] as Amf3Object).getProperty("code") as Amf3String).value + } + + override fun readBody(input: InputStream) { + data.clear() + var bytesRead = 0 + while (bytesRead < header.messageLength) { + val amf3Data = Amf3Data.getAmf3Data(input) + bytesRead += amf3Data.getSize() + 1 + data.add(amf3Data) + } + if (data.isNotEmpty()) { + if (data[0] is Amf3String) { + name = (data[0] as Amf3String).value + } + if (data.size >= 2 && data[1] is Amf3Double) { + commandId = (data[1] as Amf3Double).value.toInt() + } + } + bodySize = bytesRead + header.messageLength = bodySize + } + + override fun storeBody(): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + data.forEach { + it.writeHeader(byteArrayOutputStream) + it.writeBody(byteArrayOutputStream) + } + return byteArrayOutputStream.toByteArray() + } + + override fun getType(): MessageType = MessageType.COMMAND_AMF3 + + override fun toString(): String { + return "Command(name='$name', transactionId=$commandId, timeStamp=$timestamp, streamId=$streamId, data=$data, bodySize=$bodySize)" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/control/Event.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/control/Event.kt new file mode 100644 index 0000000..9cf93d0 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/control/Event.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message.control + +/** + * Created by pedro on 21/04/21. + */ +data class Event(val data: Int, val bufferLength: Int = -1) diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/control/Type.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/control/Type.kt new file mode 100644 index 0000000..3c353ef --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/control/Type.kt @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message.control + +/** + * Created by pedro on 21/04/21. + */ +enum class Type(val mark: Byte) { + /** + * Type: 0 + * The server sends this event to notify the client that a stream has become + * functional and can be used for communication. By default, this event + * is sent on ID 0 after the application connect command is successfully + * received from the client. + * + * Event Data: + * eventData[0] (int) the stream ID of the stream that became functional + */ + STREAM_BEGIN(0x00), + + /** + * Type: 1 + * The server sends this event to notify the client that the playback of + * data is over as requested on this stream. No more data is sent without + * issuing additional commands. The client discards the messages received + * for the stream. + * + * Event Data: + * eventData[0]: the ID of thestream on which playback has ended. + */ + STREAM_EOF(0x01), + + /** + * Type: 2 + * The server sends this event to notify the client that there is no + * more data on the stream. If the server does not detect any message for + * a time period, it can notify the subscribed clients that the stream is + * dry. + * + * Event Data: + * eventData[0]: the stream ID of the dry stream. + */ + STREAM_DRY(0x02), + + /** + * Type: 3 + * The client sends this event to inform the server of the buffer size + * (in milliseconds) that is used to buffer any data coming over a stream. + * This event is sent before the server starts processing the stream. + * + * Event Data: + * eventData[0]: the stream ID and + * eventData[1]: the buffer length, in milliseconds. + */ + SET_BUFFER_LENGTH(0x03), + + /** + * Type: 4 + * The server sends this event to notify the client that the stream is a + * recorded stream. + * + * Event Data: + * eventData[0]: the stream ID of the recorded stream. + */ + STREAM_IS_RECORDED(0x04), + + /** + * Type: 6 + * The server sends this event to test whether the client is reachable. + * + * Event Data: + * eventData[0]: a timestamp representing the local server time when the server dispatched the command. + * + * The client responds with PING_RESPONSE on receiving PING_REQUEST. + */ + PING_REQUEST(0x06), + + /** + * Type: 7 + * The client sends this event to the server in response to the ping request. + * + * Event Data: + * eventData[0]: the 4-byte timestamp which was received with the PING_REQUEST. + */ + PONG_REPLY(0x07), + + /** + * Type: 31 (0x1F) + * + * This user control type is not specified in any official documentation, but + * is sent by Flash Media Server 3.5. Thanks to the rtmpdump devs for their + * explanation: + * + * Buffer Empty (unofficial name): After the server has sent a complete buffer, and + * sends this Buffer Empty message, it will wait until the play + * duration of that buffer has passed before sending a new buffer. + * The Buffer Ready message will be sent when the new buffer starts. + * + * (see also: http://repo.or.cz/w/rtmpdump.git/blob/8880d1456b282ee79979adbe7b6a6eb8ad371081:/librtmp/rtmp.c#l2787) + */ + BUFFER_EMPTY(0x1F), + + /** + * Type: 32 (0x20) + * + * This user control type is not specified in any official documentation, but + * is sent by Flash Media Server 3.5. Thanks to the rtmpdump devs for their + * explanation: + * + * Buffer Ready (unofficial name): After the server has sent a complete buffer, and + * sends a Buffer Empty message, it will wait until the play + * duration of that buffer has passed before sending a new buffer. + * The Buffer Ready message will be sent when the new buffer starts. + * (There is no BufferReady message for the very first buffer; + * presumably the Stream Begin message is sufficient for that + * purpose.) + * + * (see also: http://repo.or.cz/w/rtmpdump.git/blob/8880d1456b282ee79979adbe7b6a6eb8ad371081:/librtmp/rtmp.c#l2787) + */ + BUFFER_READY(0x20) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/control/UserControl.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/control/UserControl.kt new file mode 100644 index 0000000..ef771a8 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/control/UserControl.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message.control + +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.rtmp.message.BasicHeader +import com.pedro.rtmp.rtmp.message.MessageType +import com.pedro.rtmp.rtmp.message.RtmpMessage +import com.pedro.rtmp.utils.readUInt16 +import com.pedro.rtmp.utils.readUInt32 +import com.pedro.rtmp.utils.writeUInt16 +import com.pedro.rtmp.utils.writeUInt32 +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream + +/** + * Created by pedro on 21/04/21. + */ +class UserControl(var type: Type = Type.PING_REQUEST, var event: Event = Event(-1, -1)): RtmpMessage(BasicHeader(ChunkType.TYPE_0, ChunkStreamId.PROTOCOL_CONTROL.mark)) { + + private val TAG = "UserControl" + private var bodySize = 6 + + override fun readBody(input: InputStream) { + bodySize = 0 + val t = input.readUInt16() + type = Type.entries.find { it.mark.toInt() == t } ?: throw IOException("unknown user control type: $t") + bodySize += 2 + val data = input.readUInt32() + bodySize += 4 + event = if (type == Type.SET_BUFFER_LENGTH) { + val bufferLength = input.readUInt32() + Event(data, bufferLength) + } else { + Event(data) + } + } + + override fun storeBody(): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + byteArrayOutputStream.writeUInt16(type.mark.toInt()) + byteArrayOutputStream.writeUInt32(event.data) + if (event.bufferLength != -1) { + byteArrayOutputStream.writeUInt32(event.bufferLength) + } + return byteArrayOutputStream.toByteArray() + } + + override fun getType(): MessageType = MessageType.USER_CONTROL + + override fun getSize(): Int = bodySize + + override fun toString(): String { + return "UserControl(type=$type, event=$event, bodySize=$bodySize)" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/data/Data.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/data/Data.kt new file mode 100644 index 0000000..5479f26 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/data/Data.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message.data + +import com.pedro.rtmp.rtmp.message.BasicHeader +import com.pedro.rtmp.rtmp.message.RtmpMessage + +/** + * Created by pedro on 21/04/21. + */ +abstract class Data(timeStamp: Int, streamId: Int, basicHeader: BasicHeader): RtmpMessage(basicHeader) { + + protected var bodySize = 0 + + init { + header.messageLength = bodySize + header.timeStamp = timeStamp + header.messageStreamId = streamId + } + + override fun getSize(): Int = bodySize +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/data/DataAmf0.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/data/DataAmf0.kt new file mode 100644 index 0000000..7cfa32f --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/data/DataAmf0.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message.data + +import com.pedro.rtmp.amf.v0.AmfData +import com.pedro.rtmp.amf.v0.AmfString +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.rtmp.message.BasicHeader +import com.pedro.rtmp.rtmp.message.MessageType +import java.io.ByteArrayOutputStream +import java.io.InputStream + +/** + * Created by pedro on 21/04/21. + */ +class DataAmf0(private var name: String = "", timeStamp: Int = 0, streamId: Int = 0, basicHeader: BasicHeader = BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_CONNECTION.mark)) : + Data(timeStamp, streamId, basicHeader) { + + private val data: MutableList = mutableListOf() + + init { + val amfString = AmfString(name) + bodySize += amfString.getSize() + 1 + data.forEach { + bodySize += it.getSize() + 1 + } + } + + fun addData(amfData: AmfData) { + data.add(amfData) + bodySize += amfData.getSize() + 1 + header.messageLength = bodySize + } + + override fun readBody(input: InputStream) { + data.clear() + bodySize = 0 + val amfString = AmfString() + amfString.readHeader(input) + amfString.readBody(input) + name = amfString.value + bodySize += amfString.getSize() + 1 + while (bodySize < header.messageLength) { + val amfData = AmfData.getAmfData(input) + data.add(amfData) + bodySize += amfData.getSize() + 1 + } + } + + override fun storeBody(): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + val amfString = AmfString(name) + amfString.writeHeader(byteArrayOutputStream) + amfString.writeBody(byteArrayOutputStream) + data.forEach { + it.writeHeader(byteArrayOutputStream) + it.writeBody(byteArrayOutputStream) + } + return byteArrayOutputStream.toByteArray() + } + + override fun getType(): MessageType = MessageType.DATA_AMF0 + + override fun toString(): String { + return "Data(name='$name', data=$data, bodySize=$bodySize)" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/data/DataAmf3.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/data/DataAmf3.kt new file mode 100644 index 0000000..d86a7e0 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/data/DataAmf3.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message.data + +import com.pedro.rtmp.amf.v3.Amf3Data +import com.pedro.rtmp.amf.v3.Amf3String +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.rtmp.message.BasicHeader +import com.pedro.rtmp.rtmp.message.MessageType +import java.io.ByteArrayOutputStream +import java.io.InputStream + +/** + * Created by pedro on 21/04/21. + */ +class DataAmf3(private val name: String = "", timeStamp: Int = 0, streamId: Int = 0, basicHeader: BasicHeader = BasicHeader(ChunkType.TYPE_0, ChunkStreamId.OVER_CONNECTION.mark)): + Data(timeStamp, streamId, basicHeader) { + + private val data: MutableList = mutableListOf() + + init { + val amf3String = Amf3String(name) + bodySize += amf3String.getSize() + 1 + data.forEach { + bodySize += it.getSize() + 1 + } + } + + fun addData(amf3Data: Amf3Data) { + data.add(amf3Data) + bodySize += amf3Data.getSize() + 1 + header.messageLength = bodySize + } + + override fun readBody(input: InputStream) { + data.clear() + bodySize = 0 + val amf3String = Amf3String() + amf3String.readHeader(input) + amf3String.readBody(input) + bodySize += amf3String.getSize() + 1 + while (bodySize < header.messageLength) { + val amf3Data = Amf3Data.getAmf3Data(input) + data.add(amf3Data) + bodySize += amf3Data.getSize() + 1 + } + } + + override fun storeBody(): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + val amf3String = Amf3String(name) + amf3String.writeHeader(byteArrayOutputStream) + amf3String.writeBody(byteArrayOutputStream) + data.forEach { + it.writeHeader(byteArrayOutputStream) + it.writeBody(byteArrayOutputStream) + } + return byteArrayOutputStream.toByteArray() + } + + override fun getType(): MessageType = MessageType.DATA_AMF3 + + override fun toString(): String { + return "Data(name='$name', data=$data, bodySize=$bodySize)" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/shared/SharedObject.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/shared/SharedObject.kt new file mode 100644 index 0000000..58d0617 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/shared/SharedObject.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message.shared + +import com.pedro.rtmp.rtmp.chunk.ChunkStreamId +import com.pedro.rtmp.rtmp.chunk.ChunkType +import com.pedro.rtmp.rtmp.message.BasicHeader +import com.pedro.rtmp.rtmp.message.RtmpMessage +import java.io.InputStream + +/** + * Created by pedro on 21/04/21. + */ +abstract class SharedObject: RtmpMessage(BasicHeader(ChunkType.TYPE_0, ChunkStreamId.PROTOCOL_CONTROL.mark)) { + override fun readBody(input: InputStream) { + TODO("Not yet implemented") + } + + override fun storeBody(): ByteArray { + TODO("Not yet implemented") + } + + override fun getSize(): Int { + TODO("Not yet implemented") + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/shared/SharedObjectAmf0.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/shared/SharedObjectAmf0.kt new file mode 100644 index 0000000..dd2be66 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/shared/SharedObjectAmf0.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message.shared + +import com.pedro.rtmp.rtmp.message.MessageType + +/** + * Created by pedro on 21/04/21. + */ +class SharedObjectAmf0: SharedObject() { + override fun getType(): MessageType = MessageType.SHARED_OBJECT_AMF0 +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/shared/SharedObjectAmf3.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/shared/SharedObjectAmf3.kt new file mode 100644 index 0000000..7aa9c3d --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/message/shared/SharedObjectAmf3.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.rtmp.message.shared + +import com.pedro.rtmp.rtmp.message.MessageType + +/** + * Created by pedro on 21/04/21. + */ +class SharedObjectAmf3: SharedObject() { + override fun getType(): MessageType = MessageType.SHARED_OBJECT_AMF3 +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/AuthUtil.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/AuthUtil.kt new file mode 100644 index 0000000..c14644a --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/AuthUtil.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.utils + +import com.pedro.common.getMd5Hash +import java.security.MessageDigest +import java.util.Random +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Created by pedro on 27/04/21. + */ +object AuthUtil { + + fun getAdobeAuthUserResult(user: String, password: String, salt: String, challenge: String, opaque: String): String { + val challenge2 = String.format("%08x", Random().nextInt()) + var response = stringToMd5Base64(user + salt + password) + if (opaque.isNotEmpty()) { + response += opaque + } else if (challenge.isNotEmpty()) { + response += challenge + } + response = stringToMd5Base64(response + challenge2) + var result = "?authmod=adobe&user=$user&challenge=$challenge2&response=$response" + if (opaque.isNotEmpty()) { + result += "&opaque=$opaque" + } + return result + } + + /** + * Limelight auth. This auth is closely to Digest auth + * http://tools.ietf.org/html/rfc2617 + * http://en.wikipedia.org/wiki/Digest_access_authentication + * + * https://github.com/ossrs/librtmp/blob/feature/srs/librtmp/rtmp.c + */ + fun getLlnwAuthUserResult(user: String, password: String, nonce: String, app: String): String { + val authMod = "llnw" + val realm = "live" + val method = "publish" + val qop = "auth" + val ncHex = String.format("%08x", 1) + val cNonce = String.format("%08x", Random().nextInt()) + var path = app + //extract query parameters + val queryPos = path.indexOf("?") + if (queryPos >= 0) path = path.substring(0, queryPos) + if (!path.contains("/")) path += "/_definst_" + val hash1 = "$user:$realm:$password".getMd5Hash() + val hash2 = "$method:/$path".getMd5Hash() + val hash3 = "$hash1:$nonce:$ncHex:$cNonce:$qop:$hash2".getMd5Hash() + return "?authmod=$authMod&user=$user&nonce=$nonce&cnonce=$cNonce&nc=$ncHex&response=$hash3" + } + + fun getSalt(description: String): String = findDescriptionValue("salt=", description) + + fun getChallenge(description: String): String = findDescriptionValue("challenge=", description) + + fun getOpaque(description: String): String = findDescriptionValue("opaque=", description) + + @OptIn(ExperimentalEncodingApi::class) + fun stringToMd5Base64(s: String): String { + try { + val md = MessageDigest.getInstance("MD5") + md.update(s.toByteArray()) + val md5hash = md.digest() + return Base64.encode(md5hash) + } catch (ignore: Exception) { } + return "" + } + + /** + * Limelight auth utils + */ + fun getNonce(description: String): String = findDescriptionValue("nonce=", description) + + private fun findDescriptionValue(value: String, description: String): String { + val data = description.split("&").toTypedArray() + for (s in data) { + if (s.contains(value)) { + return s.substring(value.length) + } + } + return "" + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/BitBuffer.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/BitBuffer.kt new file mode 100644 index 0000000..858970d --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/BitBuffer.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.utils + +import java.nio.ByteBuffer +import kotlin.math.ceil + +/** + * Created by pedro on 16/08/23. + * + */ +class BitBuffer(val buffer: ByteBuffer) { + private var bufferPosition = buffer.position() * Byte.SIZE_BITS + private val bufferEnd = buffer.limit() * Byte.SIZE_BITS + + val hasRemaining: Boolean + get() = bitRemaining > 0 + + val bitRemaining: Int + get() = bufferEnd - bufferPosition + 1 + + val remaining: Int + get() = ceil(bitRemaining.toDouble() / Byte.SIZE_BITS).toInt() + + fun getBool(): Boolean { + return getInt(1) == 1 + } + + fun get(i: Int) = getLong(i).toByte() + + fun getShort(i: Int) = getLong(i).toShort() + + fun getInt(i: Int) = getLong(i).toInt() + + fun getLong(i: Int): Long { + if (!hasRemaining) { + throw IllegalStateException("No more bits to read") + } + + val b = buffer[bufferPosition / Byte.SIZE_BITS] + val v = if (b < 0) b + 256 else b.toInt() + val left = Byte.SIZE_BITS - bufferPosition % Byte.SIZE_BITS + var rc: Long + if (i <= left) { + rc = + ((v shl (bufferPosition % Byte.SIZE_BITS) and 0xFF) shr ((bufferPosition % Byte.SIZE_BITS) + (left - i))).toLong() + bufferPosition += i + } else { + val then = i - left + rc = getLong(left) + rc = rc shl then + rc += getLong(then) + } + + buffer.position(ceil(bufferPosition.toDouble() / Byte.SIZE_BITS).toInt()) + return rc + } + + fun readUE(): Int { + var leadingZeroBits = 0 + while (!getBool()) { + leadingZeroBits++ + } + return if (leadingZeroBits > 0) { + (1 shl leadingZeroBits) - 1 + getInt(leadingZeroBits) + } else { + 0 + } + } + + companion object { + + fun extractRbsp(buffer: ByteBuffer, headerLength: Int): ByteBuffer { + val rbsp = ByteBuffer.allocateDirect(buffer.remaining()) + + val indices = buffer.indicesOf(byteArrayOf(0x00, 0x00, 0x03)) + + rbsp.put(buffer, 0, headerLength) + + var previous = buffer.position() + indices.forEach { + rbsp.put(buffer, previous, it + 2 - previous) + previous = it + 3 // skip emulation_prevention_three_byte + } + rbsp.put(buffer, previous, buffer.limit() - previous) + + rbsp.limit(rbsp.position()) + rbsp.rewind() + return rbsp + } + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/CommandSessionHistory.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/CommandSessionHistory.kt new file mode 100644 index 0000000..e0a5fbc --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/CommandSessionHistory.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.utils + +import com.pedro.rtmp.rtmp.message.RtmpHeader + +/** + * Created by pedro on 22/04/21. + */ +class CommandSessionHistory(private val commandHistory: HashMap = HashMap(), + private val headerHistory: MutableList = ArrayList()) { + + fun setReadHeader(header: RtmpHeader) { + headerHistory.add(header) + } + + fun getLastReadHeader(chunkStreamId: Int): RtmpHeader? { + val reverseList = headerHistory + reverseList.reversed().forEach { + if (it.basicHeader.chunkStreamId == chunkStreamId) { + return it + } + } + return null + } + + fun getName(id: Int): String? { + return commandHistory[id] + } + + fun setPacket(id: Int, name: String) { + commandHistory[id] = name + } + + fun reset() { + commandHistory.clear() + headerHistory.clear() + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/RtmpConfig.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/RtmpConfig.kt new file mode 100644 index 0000000..fa45a9c --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/RtmpConfig.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.utils + +/** + * Created by pedro on 26/04/21. + */ +object RtmpConfig { + const val DEFAULT_CHUNK_SIZE = 128 + var writeChunkSize = DEFAULT_CHUNK_SIZE + var acknowledgementWindowSize = Int.MAX_VALUE +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/Utils.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/Utils.kt new file mode 100644 index 0000000..ebb02b2 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/Utils.kt @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.utils + +import java.io.InputStream +import java.io.OutputStream +import java.nio.ByteBuffer + +/** + * Created by pedro on 20/04/21. + */ + +fun InputStream.readUntil(byteArray: ByteArray) { + var bytesRead = 0 + while (bytesRead < byteArray.size) { + val result = read(byteArray, bytesRead, byteArray.size - bytesRead) + if (result != -1) bytesRead += result + } +} + +fun InputStream.readUInt32(): Int { + return read() and 0xff shl 24 or (read() and 0xff shl 16) or (read() and 0xff shl 8) or (read() and 0xff) +} + +fun InputStream.readUInt24(): Int { + return read() and 0xff shl 16 or (read() and 0xff shl 8) or (read() and 0xff) +} + +fun InputStream.readUInt16(): Int { + return read() and 0xff shl 8 or (read() and 0xff) +} + +fun InputStream.readUInt32LittleEndian(): Int { + return toLittleEndian(readUInt32()) +} + +fun InputStream.readUInt24LittleEndian(): Int { + return toLittleEndian(readUInt24()) +} + +fun InputStream.readUInt16LittleEndian(): Int { + return toLittleEndian(readUInt16()) +} + +fun OutputStream.writeUInt32(value: Int) { + write(value ushr 24) + write(value ushr 16) + write(value ushr 8) + write(value) +} + +fun OutputStream.writeUInt24(value: Int) { + write(value ushr 16) + write(value ushr 8) + write(value) +} + +fun OutputStream.writeUInt16(value: Int) { + write(value ushr 8) + write(value) +} + +fun OutputStream.writeUInt32LittleEndian(value: Int) { + write(value) + write(value ushr 8) + write(value ushr 16) + write(value ushr 24) +} + +fun OutputStream.writeUInt24LittleEndian(value: Int) { + write(value) + write(value ushr 8) + write(value ushr 16) +} + +fun OutputStream.writeUInt16LittleEndian(value: Int) { + write(value) + write(value ushr 8) +} + +private fun toLittleEndian(value: Int): Int { + return Integer.reverseBytes(value) +} + +fun ByteArray.toInt(): Int { + return (this[3].toInt() shl 24) or (this[0].toInt() shl 16) or (this[3].toInt() shl 8) or this[0].toInt() +} + +fun Long.toByteArray(): ByteArray { + val buffer = ByteBuffer.allocate(Long.SIZE_BYTES) + return buffer.putLong(this).array() +} + +fun ByteBuffer.indicesOf(prefix: ByteArray): List { + if (prefix.isEmpty()) { + return emptyList() + } + + val indices = mutableListOf() + + outer@ for (i in 0 until this.limit() - prefix.size + 1) { + for (j in prefix.indices) { + if (this.get(i + j) != prefix[j]) { + continue@outer + } + } + indices.add(i) + } + return indices +} + +fun ByteBuffer.put(buffer: ByteBuffer, offset: Int, length: Int) { + val limit = buffer.limit() + buffer.position(offset) + buffer.limit(offset + length) + this.put(buffer) + buffer.limit(limit) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/socket/RtmpSocket.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/socket/RtmpSocket.kt new file mode 100644 index 0000000..504cef1 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/socket/RtmpSocket.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.utils.socket + +/** + * Created by pedro on 5/4/22. + * Socket implementation that accept: + * - TCP + * - TCP SSL/TLS + * - UDP + * - Tunneled HTTP + * - Tunneled HTTPS + */ +abstract class RtmpSocket { + + abstract suspend fun flush(isPacket: Boolean = false) + abstract suspend fun connect() + abstract suspend fun close() + abstract fun isConnected(): Boolean + abstract fun isReachable(): Boolean + abstract suspend fun write(b: Int) + abstract suspend fun write(b: ByteArray) + abstract suspend fun write(b: ByteArray, offset: Int, size: Int) + abstract suspend fun writeUInt16(b: Int) + abstract suspend fun writeUInt24(b: Int) + abstract suspend fun writeUInt32(b: Int) + abstract suspend fun writeUInt32LittleEndian(b: Int) + abstract suspend fun read(): Int + abstract suspend fun readUInt16(): Int + abstract suspend fun readUInt24(): Int + abstract suspend fun readUInt32(): Int + abstract suspend fun readUInt32LittleEndian(): Int + abstract suspend fun readUntil(b: ByteArray) +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/socket/TcpSocket.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/socket/TcpSocket.kt new file mode 100644 index 0000000..40b856c --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/socket/TcpSocket.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.utils.socket + +import com.pedro.common.socket.TcpStreamSocketImp +import javax.net.ssl.TrustManager + +/** + * Created by pedro on 5/4/22. + */ +class TcpSocket( + host: String, port: Int, secured: Boolean, certificates: TrustManager? +): RtmpSocket() { + + private val socket = TcpStreamSocketImp(host, port, secured, certificates) + + override suspend fun flush(isPacket: Boolean) { + socket.flush() + } + + override suspend fun connect() { + socket.connect() + } + + override suspend fun close() { + socket.close() + } + + override fun isConnected(): Boolean = socket.isConnected() + + override fun isReachable(): Boolean = socket.isReachable() + + override suspend fun write(b: Int) { + socket.write(b) + } + + override suspend fun write(b: ByteArray) { + socket.write(b) + } + + override suspend fun write(b: ByteArray, offset: Int, size: Int) { + socket.write(b, offset, size) + } + + override suspend fun writeUInt16(b: Int) { + socket.writeUInt16(b) + } + + override suspend fun writeUInt24(b: Int) { + socket.writeUInt24(b) + } + + override suspend fun writeUInt32(b: Int) { + socket.writeUInt32(b) + } + + override suspend fun writeUInt32LittleEndian(b: Int) { + socket.writeUInt32LittleEndian(b) + } + + override suspend fun read(): Int = socket.read() + + override suspend fun readUInt16(): Int = socket.readUInt16() + + override suspend fun readUInt24(): Int = socket.readUInt24() + + override suspend fun readUInt32(): Int = socket.readUInt32() + + override suspend fun readUInt32LittleEndian(): Int = socket.readUInt32LittleEndian() + + override suspend fun readUntil(b: ByteArray) { + socket.readUntil(b) + } +} diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/socket/TcpTunneledSocket.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/socket/TcpTunneledSocket.kt new file mode 100644 index 0000000..0c48924 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/utils/socket/TcpTunneledSocket.kt @@ -0,0 +1,233 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.rtmp.utils.socket + +// ScreenKit: upstream command/error logs may contain the stream key. +import com.pedro.common.StreamLog as Log +import com.pedro.common.TimeUtils +import com.pedro.rtmp.utils.readUInt16 +import com.pedro.rtmp.utils.readUInt24 +import com.pedro.rtmp.utils.readUInt32 +import com.pedro.rtmp.utils.readUInt32LittleEndian +import com.pedro.rtmp.utils.readUntil +import com.pedro.rtmp.utils.writeUInt16 +import com.pedro.rtmp.utils.writeUInt24 +import com.pedro.rtmp.utils.writeUInt32 +import com.pedro.rtmp.utils.writeUInt32LittleEndian +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.* +import java.net.HttpURLConnection +import java.net.SocketTimeoutException +import java.net.URL +import java.util.concurrent.atomic.AtomicLong +import javax.net.ssl.HttpsURLConnection + +/** + * Created by pedro on 5/4/22. + */ +class TcpTunneledSocket(private val host: String, private val port: Int, private val secured: Boolean): RtmpSocket() { + + private val TAG = "TcpTunneledSocket" + + private val headers = mapOf( + "Content-Type" to "application/x-fcs", + "User-Agent" to "Shockwave Flash" + ) + private val timeout = 5000 + private var connectionId: String = "" + private var connected = false + private var index = AtomicLong(0) + private var output = ByteArrayOutputStream() + private var input = ByteArrayInputStream(byteArrayOf()) + private val sync = Any() + private var storedPackets = 0 + //send video/audio packets in packs of 10 on each HTTP request. + private val maxStoredPackets = 10 + + private fun getInputStream(requiredBytes: Int): InputStream { + if (input.available() >= requiredBytes) return input + synchronized(sync) { + val start = TimeUtils.getCurrentTimeMillis() + while (input.available() <= 1 && connected) { + val i = index.addAndGet(1) + val bytes = requestRead("idle/$connectionId/$i", secured) + input = ByteArrayInputStream(bytes, 1, bytes.size) + if (TimeUtils.getCurrentTimeMillis() - start >= timeout) { + throw SocketTimeoutException("couldn't receive a valid packet") + } + } + } + return input + } + + @Throws(IOException::class) + private fun requestWrite(path: String, secured: Boolean, data: ByteArray) { + val socket = configureSocket(path, secured) + try { + socket.connect() + socket.outputStream.write(data) + val bytes = socket.inputStream.readBytes() + if (bytes.size > 1) input = ByteArrayInputStream(bytes, 1, bytes.size) + val success = socket.responseCode == HttpURLConnection.HTTP_OK + if (!success) throw IOException("send packet failed: ${socket.responseMessage}, broken pipe") + } finally { + socket.disconnect() + } + } + + @Throws(IOException::class) + private fun requestRead(path: String, secured: Boolean): ByteArray { + val socket = configureSocket(path, secured) + try { + socket.connect() + val data = socket.inputStream.readBytes() + val success = socket.responseCode == HttpURLConnection.HTTP_OK + if (!success) throw IOException("receive packet failed: ${socket.responseMessage}, broken pipe") + return data + } finally { + socket.disconnect() + } + } + + private fun configureSocket(path: String, secured: Boolean): HttpURLConnection { + val schema = if (secured) "https" else "http" + val url = URL("$schema://$host:$port/$path") + val socket = if (secured) { + url.openConnection() as HttpsURLConnection + } else { + url.openConnection() as HttpURLConnection + } + Log.i(TAG, "open: $url") + socket.requestMethod = "POST" + headers.forEach { (key, value) -> + socket.addRequestProperty(key, value) + } + socket.doOutput = true + socket.connectTimeout = timeout + socket.readTimeout = timeout + return socket + } + + override suspend fun flush(isPacket: Boolean) { + synchronized(sync) { + if (isPacket && storedPackets < maxStoredPackets) { + storedPackets++ + return + } + if (!connected) return + val i = index.addAndGet(1) + val bytes = output.toByteArray() + output.reset() + requestWrite("send/$connectionId/$i", secured, bytes) + storedPackets = 0 + } + } + + override suspend fun connect() { + synchronized(sync) { + try { + //optional in few servers + requestWrite("fcs/ident2", secured, byteArrayOf(0x00)) + } catch (ignored: IOException) { } + try { + val openResult = requestRead("open/1", secured) + connectionId = String(openResult).trimIndent() + requestWrite("idle/$connectionId/${index.get()}", secured, byteArrayOf(0x00)) + connected = true + Log.i(TAG, "Connection success") + } catch (e: IOException) { + Log.e(TAG, "Connection failed: ${e.message}") + connected = false + } + } + } + + override suspend fun close() { + Log.i(TAG, "closing tunneled socket...") + connected = false + synchronized(sync) { + Thread { + try { + requestWrite("close/$connectionId", secured, byteArrayOf(0x00)) + Log.i(TAG, "Close success") + } catch (e: IOException) { + Log.e(TAG, "Close request failed: ${e.message}") + } finally { + index.set(0) + connectionId = "" + } + }.start() + } + } + + override fun isConnected(): Boolean = connected + + override fun isReachable(): Boolean = connected + + override suspend fun write(b: Int) = withContext(Dispatchers.IO) { + output.write(b) + } + + override suspend fun write(b: ByteArray) = withContext(Dispatchers.IO) { + output.write(b) + } + + override suspend fun write(b: ByteArray, offset: Int, size: Int) { + output.write(b, offset, size) + } + + override suspend fun writeUInt16(b: Int) { + output.writeUInt16(b) + } + + override suspend fun writeUInt24(b: Int) { + output.writeUInt24(b) + } + + override suspend fun writeUInt32(b: Int) { + output.writeUInt32(b) + } + + override suspend fun writeUInt32LittleEndian(b: Int) { + output.writeUInt32LittleEndian(b) + } + + override suspend fun read(): Int = withContext(Dispatchers.IO) { + getInputStream(1).read() + } + + override suspend fun readUInt16(): Int { + return getInputStream(1).readUInt16() + } + + override suspend fun readUInt24(): Int { + return getInputStream(1).readUInt24() + } + + override suspend fun readUInt32(): Int { + return getInputStream(1).readUInt32() + } + + override suspend fun readUInt32LittleEndian(): Int { + return getInputStream(1).readUInt32LittleEndian() + } + + override suspend fun readUntil(b: ByteArray) { + return getInputStream(b.size).readUntil(b) + } +} diff --git a/third_party/rootencoder/rtmp/src/test/java/com/pedro/rtmp/flv/video/packet/AvcAccessUnitTest.kt b/third_party/rootencoder/rtmp/src/test/java/com/pedro/rtmp/flv/video/packet/AvcAccessUnitTest.kt new file mode 100644 index 0000000..44b57f2 --- /dev/null +++ b/third_party/rootencoder/rtmp/src/test/java/com/pedro/rtmp/flv/video/packet/AvcAccessUnitTest.kt @@ -0,0 +1,40 @@ +package com.pedro.rtmp.flv.video.packet + +import com.pedro.common.frame.MediaFrame +import com.pedro.rtmp.flv.FlvPacket +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import java.nio.ByteBuffer + +class AvcAccessUnitTest { + @Test fun splitsBothStartCodeSizesAndPreservesEmulationPrevention() { + val nals = AvcAccessUnit.split(byteArrayOf(0,0,0,1,6,9,0,0,1,0x65,0,0,3,1)) + assertEquals(2, nals.size) + assertArrayEquals(byteArrayOf(6,9), nals[0]) + assertArrayEquals(byteArrayOf(0x65,0,0,3,1), nals[1]) + } + @Test fun acceptsLengthPrefixedNal() { + assertArrayEquals(byteArrayOf(0x65,9), AvcAccessUnit.split(byteArrayOf(0,0,0,2,0x65,9)).single()) + } + @Test fun rejectsTruncatedLengths() { + assertTrue(runCatching { AvcAccessUnit.split(byteArrayOf(0,0,0,8,0x65)) }.isFailure) + } + @Test fun emitsConfigAndOneLengthPerNalWithTimestamp() = runBlocking { + val packetizer = H264Packet() + packetizer.sendVideoInfo(ByteBuffer.wrap(byteArrayOf(0,0,0,1,0x67,0x42,0,0x1f)), ByteBuffer.wrap(byteArrayOf(0,0,1,0x68,1))) + val bytes = byteArrayOf(0,0,0,1,6,9,0,0,1,0x65,10,11) + val packets = mutableListOf() + packetizer.createFlvPacket(MediaFrame(ByteBuffer.wrap(bytes), MediaFrame.Info(0, bytes.size, 2_000_000, true), MediaFrame.Type.VIDEO)) { packets += it } + assertEquals(2, packets.size) + assertEquals(0, packets[0].buffer[1].toInt()) + val payload = packets[1].buffer + assertEquals(0x17, payload[0].toInt()) + assertEquals(1, payload[1].toInt()) + val nals = AvcAccessUnit.split(payload.copyOfRange(5, payload.size)) + assertEquals(2, nals.size) + assertArrayEquals(byteArrayOf(6,9), nals[0]) + assertArrayEquals(byteArrayOf(0x65,10,11), nals[1]) + assertEquals(2000L, packets[1].timeStamp) + } +} From 12fc7959425c107099041fce111e0a3741c31301 Mon Sep 17 00:00:00 2001 From: sharthak18 <157966738+sharthak18@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:23:22 +0000 Subject: [PATCH 03/10] Enforce certificate SAN matching and complete live controls and resource cleanup Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/android-apk.yml | 14 ++++- .../services/FloatingOverlayService.kt | 32 +++++++++- .../services/ScreenRecorderEngine.kt | 16 +++-- docs/live-stream-testing.md | 5 +- third_party/rootencoder/README.md | 4 +- .../common/socket/HostnameTrustManager.kt | 59 +++++++++++++++++++ .../pedro/common/socket/TcpStreamSocketImp.kt | 8 ++- .../java/com/pedro/common/TlsSocketTest.kt | 4 +- .../common/socket/HostnameTrustManagerTest.kt | 18 ++++++ 9 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 third_party/rootencoder/common/src/main/java/com/pedro/common/socket/HostnameTrustManager.kt create mode 100644 third_party/rootencoder/common/src/test/java/com/pedro/common/socket/HostnameTrustManagerTest.kt diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 9f431a7..4b99073 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -61,12 +61,24 @@ jobs: echo '
Build log tail' echo echo '```text' - tail -c 30000 /tmp/gradle-build.log + tail -c 24000 /tmp/gradle-build.log + find app/build/test-results third_party/rootencoder -path '*/test-results/*/*.xml' -type f -exec grep -A 12 '/dev/null | head -80 || true echo '```' echo '
' } > /tmp/diagnostics.md gh issue comment 4 --body-file /tmp/diagnostics.md + - name: Upload unit test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: unit-test-reports + path: | + app/build/reports/tests/ + third_party/rootencoder/*/build/reports/tests/ + if-no-files-found: ignore + retention-days: 14 + - name: Name APK for download run: cp app/build/outputs/apk/debug/app-debug.apk ScreenKit.apk diff --git a/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt b/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt index 73b22fd..cb0f5c8 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt @@ -59,6 +59,20 @@ class FloatingOverlayService : Service() { private var windowManager: WindowManager? = null private var overlayRoot: LinearLayout? = null private var overlayParams: WindowManager.LayoutParams? = null + private var liveStatusLabel: TextView? = null + private val liveTicker = object : Runnable { + override fun run() { + val label = liveStatusLabel ?: return + if (!ScreenCaptureService.isLive) return + val status = com.screenkit.screenrecorder.streaming.LiveStreamSession.status.value + val seconds = if (status.startedAt == 0L) 0L else + ((System.currentTimeMillis() - status.startedAt) / 1000).coerceAtLeast(0L) + label.text = if (status.connected) { + "LIVE · %02d:%02d · %d kbps%s".format(seconds / 60, seconds % 60, status.bitrate / 1000, if (status.muted) " · MUTED" else "") + } else "Connecting to live server…" + mainHandler.postDelayed(this, 1000L) + } + } private var expanded = false private var screenRecording = false private var cameraRecording = false @@ -325,6 +339,8 @@ class FloatingOverlayService : Service() { val manager = windowManager ?: return val metrics = realDisplayMetrics() val beforeWidth = root.width + mainHandler.removeCallbacks(liveTicker) + liveStatusLabel = null root.removeAllViews() root.orientation = LinearLayout.HORIZONTAL root.gravity = Gravity.CENTER_VERTICAL @@ -352,7 +368,7 @@ class FloatingOverlayService : Service() { menu, LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, - dp(CELL_HEIGHT_DP * 2), + dp(CELL_HEIGHT_DP * 2 + if (ScreenCaptureService.isLive) 24 else 0), ), ) root.addView( @@ -368,7 +384,7 @@ class FloatingOverlayService : Service() { menu, LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, - dp(CELL_HEIGHT_DP * 2), + dp(CELL_HEIGHT_DP * 2 + if (ScreenCaptureService.isLive) 24 else 0), ).apply { leftMargin = dp(4) }, ) } @@ -465,6 +481,16 @@ class FloatingOverlayService : Service() { */ private fun makeMenu(): LinearLayout { val column = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + if (ScreenCaptureService.isLive) { + liveStatusLabel = TextView(this).apply { + textSize = 11f + setTextColor(0xFFFFD3D7.toInt()) + gravity = Gravity.CENTER + setSingleLine(true) + } + column.addView(liveStatusLabel, LinearLayout.LayoutParams(dp(CELL_WIDTH_DP * COLUMNS), dp(24))) + mainHandler.post(liveTicker) + } val soundActive = SettingsStore.audioMode(this) != CaptureAudioMode.NONE val touchesActive = SettingsStore.showTouches(this) val brushActive = BrushOverlay.isShowing @@ -1146,6 +1172,8 @@ class FloatingOverlayService : Service() { } private fun removeOverlay() { + mainHandler.removeCallbacks(liveTicker) + liveStatusLabel = null mainHandler.removeCallbacks(safetyRestoreRunnable) snapAnimator?.cancel() snapAnimator = null diff --git a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenRecorderEngine.kt b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenRecorderEngine.kt index 71534ad..12835ea 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenRecorderEngine.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenRecorderEngine.kt @@ -113,7 +113,10 @@ internal class ScreenRecorderEngine( setInteger(MediaFormat.KEY_BIT_RATE, bitRate) setInteger(MediaFormat.KEY_FRAME_RATE, frameRate) setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) - if (publisher != null) setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline) + if (publisher != null) { + setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, frameRate.toFloat()) + } setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR) setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, 100_000L) } @@ -234,13 +237,15 @@ internal class ScreenRecorderEngine( setInteger(MediaFormat.KEY_BIT_RATE, 128_000) setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16 * 1_024) } + var creatingCodec: MediaCodec? = null val codec = runCatching { - MediaCodec.createEncoderByType(AUDIO_MIME).apply { + MediaCodec.createEncoderByType(AUDIO_MIME).also { creatingCodec = it }.apply { configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) start() } }.getOrNull() if (codec == null) { + runCatching { creatingCodec?.release() } if (publisher != null) error("No AAC encoder is available for streaming.") onAudioUnavailable("No audio encoder is available; this recording is silent.") return @@ -541,8 +546,9 @@ internal class ScreenRecorderEngine( devChunk = runCatching { dev.queue.poll(22L, TimeUnit.MILLISECONDS) }.getOrNull() micChunk = null } else { - // Live destinations still get a paced silent AAC track when sound is off. - runCatching { Thread.sleep(20L) } + // Pace by the sample clock rather than adding sleep + processing time (which drifts). + val dueAt = sessionStartElapsed + (totalAudioSamplesFed + SAMPLES_PER_CHUNK) * 1000L / SAMPLE_RATE + runCatching { Thread.sleep((dueAt - SystemClock.elapsedRealtime()).coerceIn(1L, 20L)) } micChunk = null devChunk = null } @@ -591,7 +597,7 @@ internal class ScreenRecorderEngine( val ptsUs = (totalAudioSamplesFed * 1_000_000L) / SAMPLE_RATE.toLong() totalAudioSamplesFed += samplesInBuffer lastAudioInputPtsUs = ptsUs - runCatching { codec.queueInputBuffer(index, 0, size, ptsUs, 0) } + runCatching { codec.queueInputBuffer(index, 0, size, ptsUs, 0) }.onFailure { publisher?.encoderError() } offset += size } } diff --git a/docs/live-stream-testing.md b/docs/live-stream-testing.md index 6cf615c..1c76b99 100644 --- a/docs/live-stream-testing.md +++ b/docs/live-stream-testing.md @@ -2,8 +2,9 @@ ## Automated -`gradle :app:testDebugUnitTest :app:assembleDebug` runs endpoint/quality/dimension tests -and compiles the APK. Tests must pass before the APK publish step in CI. +`gradle :stream-common:testDebugUnitTest :stream-rtmp:testDebugUnitTest :app:testDebugUnitTest :app:assembleDebug` +runs endpoint/quality/dimension, real local TLS handshake, escaped token, and AVC +packetization tests and compiles the APK. Tests must pass before the APK publish step in CI. ## Device / private-server tests (manual, require an Android device) diff --git a/third_party/rootencoder/README.md b/third_party/rootencoder/README.md index 08ee1c0..fd35b82 100644 --- a/third_party/rootencoder/README.md +++ b/third_party/rootencoder/README.md @@ -15,7 +15,9 @@ Gradle files and manifests are adapted to ScreenKit's toolchain and minSdk 24. only suppresses packet logs; command logs include private stream names/keys. - `TcpStreamSocketImp` sets TLS `serverName`. Ktor 3.0.0 uses this for SNI **and certificate hostname verification**; without it only the certificate chain is checked. - Platform/system trust remains enabled; no trust-all manager is installed. + A strict SAN-based trust wrapper additionally rejects missing-name/IP SANs, which + Ktor 3.0.0 otherwise accepts. Platform/system trust remains enabled; no trust-all + manager is installed. Wildcards match exactly one leftmost label. - Own the raw TCP socket before TLS, ensuring failed/cancelled handshakes can close it. A null socket is no longer reported as connected. - `RtmpClient` reports read-side server failures to its connection callback, so the diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/HostnameTrustManager.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/HostnameTrustManager.kt new file mode 100644 index 0000000..787357c --- /dev/null +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/HostnameTrustManager.kt @@ -0,0 +1,59 @@ +package com.pedro.common.socket + +import java.net.IDN +import java.net.InetAddress +import java.security.KeyStore +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import java.util.Locale +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +/** + * ScreenKit: require a matching SAN as well as a valid trust chain. Ktor 3.0.0's + * verifier accepts certificates with no SAN of the requested type (notably IPs). + */ +internal class HostnameTrustManager( + private val host: String, + private val delegate: X509TrustManager = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + .apply { init(null as KeyStore?) }.trustManagers.filterIsInstance().first(), +) : X509TrustManager { + override fun getAcceptedIssuers(): Array = delegate.acceptedIssuers + override fun checkClientTrusted(chain: Array, authType: String) = delegate.checkClientTrusted(chain, authType) + override fun checkServerTrusted(chain: Array, authType: String) { + delegate.checkServerTrusted(chain, authType) + val certificate = chain.firstOrNull() ?: throw CertificateException("Missing server certificate") + val target = host.removePrefix("[").removeSuffix("]") + val isIp = target.contains(':') || target.matches(Regex("[0-9.]+")) + val matching = certificate.subjectAlternativeNames.orEmpty().any { san -> + if (san.size < 2) false + else if (isIp && san[0] == 7) { + runCatching { + val address = when (val value = san[1]) { + is String -> InetAddress.getByName(value).address + is ByteArray -> value + else -> byteArrayOf() + } + InetAddress.getByName(target).address.contentEquals(address) + }.getOrDefault(false) + } else if (!isIp && san[0] == 2) { + matchesDns(target, san[1] as? String ?: "") + } else false + } + if (!matching) throw CertificateException("Server certificate does not match the destination hostname") + } + + companion object { + internal fun matchesDns(host: String, pattern: String): Boolean = runCatching { + fun normalize(value: String) = IDN.toASCII(value.trimEnd('.'), IDN.USE_STD3_ASCII_RULES).lowercase(Locale.ROOT) + val target = normalize(host) + if (!pattern.startsWith("*.")) target == normalize(pattern) + else { + val suffix = normalize(pattern.substring(2)) + // Only one complete leftmost label may be a wildcard, never *.com. + suffix.contains('.') && target.endsWith(".$suffix") && + target.removeSuffix(".$suffix").let { it.isNotEmpty() && !it.contains('.') } + } + }.getOrDefault(false) + } +} diff --git a/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/TcpStreamSocketImp.kt b/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/TcpStreamSocketImp.kt index 806314d..eac8710 100644 --- a/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/TcpStreamSocketImp.kt +++ b/third_party/rootencoder/common/src/main/java/com/pedro/common/socket/TcpStreamSocketImp.kt @@ -54,7 +54,9 @@ class TcpStreamSocketImp( builder.tls(Dispatchers.Default) { // SNI and certificate hostname verification (Ktor only verifies names when supplied). serverName = host.removePrefix("[").removeSuffix("]") - trustManager = certificate + trustManager = if (certificate is javax.net.ssl.X509TrustManager) { + HostnameTrustManager(host, certificate) + } else HostnameTrustManager(host) random = SecureRandom() } } else builder @@ -69,7 +71,9 @@ class TcpStreamSocketImp( address = null input = null output = null - socket?.close() + val current = socket + socket = null + current?.close() selectorManager.close() } catch (ignored: Exception) {} } diff --git a/third_party/rootencoder/common/src/test/java/com/pedro/common/TlsSocketTest.kt b/third_party/rootencoder/common/src/test/java/com/pedro/common/TlsSocketTest.kt index d45c4b7..b238db3 100644 --- a/third_party/rootencoder/common/src/test/java/com/pedro/common/TlsSocketTest.kt +++ b/third_party/rootencoder/common/src/test/java/com/pedro/common/TlsSocketTest.kt @@ -37,9 +37,9 @@ class TlsSocketTest { @Test(timeout = 15000) fun acceptsTrustedMatchingHostname() = withServer { port, trust -> runBlocking { val socket = TcpStreamSocketImp("localhost", port, true, trust) - try { withTimeout(7000) { socket.connect() }; assertTrue(socket.isConnected()) } + try { withTimeout(7000) { socket.connect() }; assertTrue("TLS socket should be connected", socket.isConnected()) } finally { socket.close() } - assertFalse(socket.isConnected()) + assertFalse("Closed socket must not report connected", socket.isConnected()) } } diff --git a/third_party/rootencoder/common/src/test/java/com/pedro/common/socket/HostnameTrustManagerTest.kt b/third_party/rootencoder/common/src/test/java/com/pedro/common/socket/HostnameTrustManagerTest.kt new file mode 100644 index 0000000..fc730c6 --- /dev/null +++ b/third_party/rootencoder/common/src/test/java/com/pedro/common/socket/HostnameTrustManagerTest.kt @@ -0,0 +1,18 @@ +package com.pedro.common.socket + +import org.junit.Assert.* +import org.junit.Test + +class HostnameTrustManagerTest { + @Test fun exactAndSingleLabelWildcardOnly() { + assertTrue(HostnameTrustManager.matchesDns("LIVE.Example.com", "live.example.com")) + assertTrue(HostnameTrustManager.matchesDns("live.example.com", "*.example.com")) + assertFalse(HostnameTrustManager.matchesDns("example.com", "*.example.com")) + assertFalse(HostnameTrustManager.matchesDns("a.live.example.com", "*.example.com")) + assertFalse(HostnameTrustManager.matchesDns("badexample.com", "*.example.com")) + assertFalse(HostnameTrustManager.matchesDns("example.com", "*.com")) + assertFalse(HostnameTrustManager.matchesDns("a.example.com", "a.*.com")) + assertFalse(HostnameTrustManager.matchesDns("a.example.com", "b.example.com")) + assertFalse(HostnameTrustManager.matchesDns("a.example.com", "")) + } +} From 0fd07df6dbc5e37a0b5a9289c1ba6b5fc5f0f8f9 Mon Sep 17 00:00:00 2001 From: sharthak18 <157966738+sharthak18@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:26:30 +0000 Subject: [PATCH 04/10] Avoid first-keyframe startup race and tie release metadata to built commit Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/android-apk.yml | 1 + README.md | 2 +- third_party/rootencoder/README.md | 3 ++- .../rtmp/src/main/java/com/pedro/rtmp/rtmp/RtmpClient.kt | 8 +++++--- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 4b99073..9a08241 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -95,6 +95,7 @@ jobs: with: tag_name: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.1.0' }} name: ScreenKit ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.1.0' }} + target_commitish: ${{ github.sha }} make_latest: true generate_release_notes: true files: ScreenKit.apk diff --git a/README.md b/README.md index 404eb1d..abde858 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ScreenKit (v2.1.0) -A native, high-performance Android capture, creator studio & screen automation suite built with Modern Android Architecture (Jetpack Compose, CameraX, Foreground Services, Accessibility Gesture Engine, MediaProjection, OpenGL ES, MediaMuxer/MediaExtractor, and MediaCodec). ScreenKit v2.1.0 delivers screen recording with **Pause & Resume**, custom area cropping, **Scrolling Long Screenshots ("Stitch Full Page")**, built-in **MP4 Video Trimmer, Animated GIF Maker & PNG Frame Extractor**, sequential multi-target **Auto Clicking (`1 → 2 → 3 → 4…`)**, directional **Auto Scrolling (`↑ / ↓ / ← / →`)**, **Touch Macro Recorder & Replay**, **Saved Auto-Clicker Profiles (Slots 1–3)**, **Anti-Detection Human Jitter**, **Smart Color / Pixel Trigger (`#HEX`)**, **Floating Teleprompter**, **On-Screen Magnifier Loupe & Color Eyedropper**, **Creator Watermark Badge**, **Shake / Volume-Down Emergency Stop**, **Live Usage Analytics & One-Tap In-App Updater**, live interactive brush annotation, on-screen touch visualization, a draggable floating camera lens, amplified sound recording (Mic / Device Audio / Mixed), and a built-in Black-Screen Bypass Web Capture Browser. +A native, high-performance Android capture, creator studio & screen automation suite built with Modern Android Architecture (Jetpack Compose, CameraX, Foreground Services, Accessibility Gesture Engine, MediaProjection, OpenGL ES, MediaMuxer/MediaExtractor, and MediaCodec). ScreenKit v2.1.0 adds **Go Live screen streaming to YouTube, Facebook, and custom RTMP/RTMPS destinations** alongside screen recording with **Pause & Resume**, custom area cropping, **Scrolling Long Screenshots ("Stitch Full Page")**, built-in **MP4 Video Trimmer, Animated GIF Maker & PNG Frame Extractor**, sequential multi-target **Auto Clicking (`1 → 2 → 3 → 4…`)**, directional **Auto Scrolling (`↑ / ↓ / ← / →`)**, **Touch Macro Recorder & Replay**, **Saved Auto-Clicker Profiles (Slots 1–3)**, **Anti-Detection Human Jitter**, **Smart Color / Pixel Trigger (`#HEX`)**, **Floating Teleprompter**, **On-Screen Magnifier Loupe & Color Eyedropper**, **Creator Watermark Badge**, **Shake / Volume-Down Emergency Stop**, **Live Usage Analytics & One-Tap In-App Updater**, live interactive brush annotation, on-screen touch visualization, a draggable floating camera lens, amplified sound recording (Mic / Device Audio / Mixed), and a built-in Black-Screen Bypass Web Capture Browser. --- diff --git a/third_party/rootencoder/README.md b/third_party/rootencoder/README.md index fd35b82..2986c53 100644 --- a/third_party/rootencoder/README.md +++ b/third_party/rootencoder/README.md @@ -20,7 +20,8 @@ Gradle files and manifests are adapted to ScreenKit's toolchain and minSdk 24. manager is installed. Wildcards match exactly one leftmost label. - Own the raw TCP socket before TLS, ensuring failed/cancelled handshakes can close it. A null socket is no longer reported as connected. -- `RtmpClient` reports read-side server failures to its connection callback, so the +- `RtmpClient` starts its send queue before the success callback so the first keyframe + cannot be discarded by a startup race. It also reports read-side server failures to its connection callback, so the capture service shuts down instead of showing a stale LIVE status. - `UrlParser` preserves raw path/query encoding, preventing percent-escaped stream tokens from being decoded or duplicated. Transport supports one app/stream endpoint. diff --git a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/RtmpClient.kt b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/RtmpClient.kt index 869efc4..812b0dc 100644 --- a/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/RtmpClient.kt +++ b/third_party/rootencoder/rtmp/src/main/java/com/pedro/rtmp/rtmp/RtmpClient.kt @@ -468,12 +468,14 @@ class RtmpClient(private val connectChecker: ConnectChecker) { when (val code = command.getCode()) { "NetStream.Publish.Start" -> { commandsManager.sendMetadata(socket) - onMainThread { - connectChecker.onConnectionSuccess() - } + // ScreenKit: make the sender ready before allowing the first keyframe. + // Otherwise the success callback races with start() clearing its queue. rtmpSender.socket = socket rtmpSender.start() publishPermitted = true + onMainThread { + connectChecker.onConnectionSuccess() + } } "NetConnection.Connect.Rejected", "NetStream.Publish.BadName" -> { onMainThread { From ef0c04aacaffb83a9e73e37602053ae27d9b03b4 Mon Sep 17 00:00:00 2001 From: sharthak18 <157966738+sharthak18@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:51:43 +0000 Subject: [PATCH 05/10] Add native iOS app with ReplayKit broadcasting and on-device video studio Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/android-apk.yml | 6 + .github/workflows/ios-build.yml | 72 +++++++++ .gitignore | 12 ++ README.md | 8 + ios/BroadcastUpload/BroadcastPipeline.swift | 149 ++++++++++++++++++ .../BroadcastUpload.entitlements | 14 ++ ios/BroadcastUpload/Info.plist | 33 ++++ ios/BroadcastUpload/PrivacyInfo.xcprivacy | 23 +++ ios/BroadcastUpload/ReplayVideoScaler.swift | 44 ++++++ ios/BroadcastUpload/SampleHandler.swift | 59 +++++++ ios/README.md | 100 ++++++++++++ .../AppIcon.appiconset/AppIcon.png | Bin 0 -> 10007 bytes .../AppIcon.appiconset/Contents.json | 14 ++ ios/ScreenKit/Assets.xcassets/Contents.json | 1 + ios/ScreenKit/Info.plist | 49 ++++++ ios/ScreenKit/LiveModel.swift | 133 ++++++++++++++++ ios/ScreenKit/MediaLibrary.swift | 64 ++++++++ ios/ScreenKit/PhotoVideoPicker.swift | 33 ++++ ios/ScreenKit/PrivacyInfo.xcprivacy | 23 +++ ios/ScreenKit/RecordingModel.swift | 46 ++++++ ios/ScreenKit/ScreenKit.entitlements | 14 ++ ios/ScreenKit/ScreenKitApp.swift | 139 ++++++++++++++++ ios/ScreenKit/StudioView.swift | 78 +++++++++ ios/ScreenKit/ThirdPartyNotices.txt | 66 ++++++++ ios/ScreenKit/VideoTools.swift | 61 +++++++ ios/ScreenKitCore/Package.swift | 9 ++ .../Sources/ScreenKitCore/Models.swift | 109 +++++++++++++ .../Tests/ScreenKitCoreTests/CoreTests.swift | 74 +++++++++ ios/Shared/BroadcastStore.swift | 66 ++++++++ ios/TESTING.md | 43 +++++ ios/project.yml | 71 +++++++++ ios/scripts/generate.sh | 5 + 32 files changed, 1618 insertions(+) create mode 100644 .github/workflows/ios-build.yml create mode 100644 ios/BroadcastUpload/BroadcastPipeline.swift create mode 100644 ios/BroadcastUpload/BroadcastUpload.entitlements create mode 100644 ios/BroadcastUpload/Info.plist create mode 100644 ios/BroadcastUpload/PrivacyInfo.xcprivacy create mode 100644 ios/BroadcastUpload/ReplayVideoScaler.swift create mode 100644 ios/BroadcastUpload/SampleHandler.swift create mode 100644 ios/README.md create mode 100644 ios/ScreenKit/Assets.xcassets/AppIcon.appiconset/AppIcon.png create mode 100644 ios/ScreenKit/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/ScreenKit/Assets.xcassets/Contents.json create mode 100644 ios/ScreenKit/Info.plist create mode 100644 ios/ScreenKit/LiveModel.swift create mode 100644 ios/ScreenKit/MediaLibrary.swift create mode 100644 ios/ScreenKit/PhotoVideoPicker.swift create mode 100644 ios/ScreenKit/PrivacyInfo.xcprivacy create mode 100644 ios/ScreenKit/RecordingModel.swift create mode 100644 ios/ScreenKit/ScreenKit.entitlements create mode 100644 ios/ScreenKit/ScreenKitApp.swift create mode 100644 ios/ScreenKit/StudioView.swift create mode 100644 ios/ScreenKit/ThirdPartyNotices.txt create mode 100644 ios/ScreenKit/VideoTools.swift create mode 100644 ios/ScreenKitCore/Package.swift create mode 100644 ios/ScreenKitCore/Sources/ScreenKitCore/Models.swift create mode 100644 ios/ScreenKitCore/Tests/ScreenKitCoreTests/CoreTests.swift create mode 100644 ios/Shared/BroadcastStore.swift create mode 100644 ios/TESTING.md create mode 100644 ios/project.yml create mode 100755 ios/scripts/generate.sh diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 9a08241..b335efb 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -6,6 +6,12 @@ on: branches: - "main" - "arena/**" + paths: + - "app/**" + - "third_party/**" + - "*.gradle.kts" + - "gradle.properties" + - ".github/workflows/android-apk.yml" tags: - "v*" diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml new file mode 100644 index 0000000..81a7c68 --- /dev/null +++ b/.github/workflows/ios-build.yml @@ -0,0 +1,72 @@ +name: iOS Build +on: + workflow_dispatch: + push: + branches: [main, 'arena/**'] + paths: ['ios/**', '.github/workflows/ios-build.yml'] + pull_request: + paths: ['ios/**', '.github/workflows/ios-build.yml'] +permissions: + contents: read + issues: write +concurrency: + group: ios-${{ github.ref }} + cancel-in-progress: true +jobs: + build: + runs-on: macos-15 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Toolchain + run: xcodebuild -version && swift --version + - name: Core unit tests + run: | + set -o pipefail + swift test --package-path ios/ScreenKitCore 2>&1 | tee /tmp/ios-tests.log + - name: Generate native Xcode project + run: | + brew install xcodegen + ios/scripts/generate.sh + - name: Build iPhone and iPad app with broadcast extension (unsigned) + run: | + set -o pipefail + xcodebuild -project ios/ScreenKit.xcodeproj -scheme ScreenKit -configuration Release \ + -destination 'generic/platform=iOS' -derivedDataPath ios/build/device \ + CODE_SIGNING_ALLOWED=NO build 2>&1 | tee /tmp/ios-build.log + - name: Build simulator app + run: | + set -o pipefail + xcodebuild -project ios/ScreenKit.xcodeproj -scheme ScreenKit -configuration Debug \ + -destination 'generic/platform=iOS Simulator' -derivedDataPath ios/build/simulator \ + CODE_SIGNING_ALLOWED=NO build 2>&1 | tee /tmp/ios-simulator.log + ditto -c -k --sequesterRsrc --keepParent ios/build/simulator/Build/Products/Debug-iphonesimulator/ScreenKit.app ios/build/ScreenKit-Simulator.zip + - name: Build artifacts (not installable on iPhone) + uses: actions/upload-artifact@v4 + with: + name: ScreenKit-iOS-unsigned-and-simulator + path: | + ios/build/ScreenKit-Simulator.zip + ios/build/device/Build/Products/Release-iphoneos/ + ios/ScreenKit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved + retention-days: 14 + - name: Post diagnostics on failure + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + { + echo "iOS build failed on ${{ github.ref_name }} @ ${{ github.sha }}" + echo "Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + echo '```text' + cat /tmp/ios-tests.log /tmp/ios-build.log /tmp/ios-simulator.log 2>/dev/null | tail -c 24000 || true + echo '```' + } > /tmp/ios-diagnostics.md + gh issue comment 4 --body-file /tmp/ios-diagnostics.md + - name: Logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: ios-build-logs + path: /tmp/ios-*.log + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index a1507f8..47eb3c3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,15 @@ captures/ # OS/editor noise .DS_Store Thumbs.db + +# Native iOS generated outputs / signing material +ios/ScreenKit.xcodeproj/ +**/.build/ +**/DerivedData/ +**/*.xcuserstate +**/xcuserdata/ +*.ipa +*.p12 +*.mobileprovision +# Synthetic TLS regression fixture, not a signing identity +!third_party/rootencoder/common/src/test/resources/localhost-test-only.p12 diff --git a/README.md b/README.md index abde858..27c9bf6 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,14 @@ A native, high-performance Android capture, creator studio & screen automation s --- +## iPhone and iPad + +A native **iOS 17+** companion now lives in [`ios/`](ios/README.md): SwiftUI recording, +local video tools, and a ReplayKit RTMP/RTMPS broadcast extension. It requires Xcode +and Apple signing; it is not an APK conversion or an unsigned iPhone download. +See the [iOS setup guide](ios/README.md) and [device-test checklist](ios/TESTING.md). +Android-only automation/overlay and protected-capture features are not ported. + ## Download and Install [⬇️ **Download the latest ScreenKit v2.1.0 APK**](https://github.com/sharthak18/ScreenKit/releases/latest/download/ScreenKit.apk) diff --git a/ios/BroadcastUpload/BroadcastPipeline.swift b/ios/BroadcastUpload/BroadcastPipeline.swift new file mode 100644 index 0000000..484cdf0 --- /dev/null +++ b/ios/BroadcastUpload/BroadcastPipeline.swift @@ -0,0 +1,149 @@ +import AVFoundation +import HaishinKit +import RTMPHaishinKit +@preconcurrency import Logboard +import VideoToolbox + +struct BroadcastStopped: Error {} + +/// Never forward transport errors or log payloads: RTMP server responses may contain the key. +private struct PrivateLogAppender: LBLoggerAppender { + func append(_ logboard: LBLogger, level: LBLogger.Level, message: [Any], file: StaticString, function: StaticString, line: Int) {} + func append(_ logboard: LBLogger, level: LBLogger.Level, format: String, arguments: any CVarArg, file: StaticString, function: StaticString, line: Int) {} +} + +actor BroadcastPipeline { + private let scaler = ReplayVideoScaler() + private var connection: RTMPConnection? + private var stream: RTMPStream? + private var mixer: MediaMixer? + private var store: BroadcastStore? + private var config: BroadcastConfiguration? + private var status: BroadcastStatus? + private var closed = false + private var paused = false + private var configuredSize: (Int, Int)? + private var lastVideoTime = -Double.infinity + private var lastReceivedVideo = Date() + + func start() async throws { + try Task.checkCancellation() + guard !closed else { throw CancellationError() } + LBLogger.with(kHaishinKitIdentifier).appender = PrivateLogAppender() + LBLogger.with(kRTMPHaishinKitIdentifier).appender = PrivateLogAppender() + let store = try BroadcastStore() + self.store = store + let (config, destination) = try store.consume() + self.config = config + status = .init(id: config.id, phase: .connecting, message: "Connecting to streaming server…") + try persist() + let connection = RTMPConnection(timeout: 10, requestTimeout: 5000) + let stream = RTMPStream(connection: connection) + let mixer = MediaMixer(captureSessionMode: .manual, multiTrackAudioMixingEnabled: true) + self.connection = connection; self.stream = stream; self.mixer = mixer + await stream.setVideoInputBufferCounts(1) + await mixer.setVideoMixerSettings(.init(mode: .passthrough)) + // ReplayKit app audio is the clock for mixed output; microphone-only selects the mic clock. + let audio = AudioMixerSettings(sampleRate: 48000, channels: 2, + isMuted: config.audio == .silent, + mainTrack: config.audio == .microphone ? 1 : 0, + tracks: [0: .default, 1: .default]) + await mixer.setAudioMixerSettings(audio) + await mixer.addOutput(stream) + await mixer.startRunning() + do { + try Task.checkCancellation() + guard !closed else { throw CancellationError() } + _ = try await connection.connect(destination.server) + try Task.checkCancellation() + guard !closed else { throw CancellationError() } + _ = try await stream.publish(destination.key) + try Task.checkCancellation() + guard !closed else { throw CancellationError() } + } catch { + if Task.isCancelled || closed { throw CancellationError() } + throw ScreenKitError.connection + } + status?.phase = .live; status?.message = "LIVE · Sending to server" + status?.startedAt = Date(); lastReceivedVideo = Date() + try persist() + } + + func video(_ buffer: CMSampleBuffer) async throws { + guard !closed, !paused, let stream, let mixer, let config else { return } + lastReceivedVideo = Date() + let pts = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(buffer)) + guard pts.isFinite, pts - lastVideoTime >= 1 / Double(config.quality.fps) - 0.001 else { return } + lastVideoTime = pts + guard let prepared = try scaler.convert(buffer, quality: config.quality), + let format = CMSampleBufferGetFormatDescription(prepared) else { return } + let dimensions = CMVideoFormatDescriptionGetDimensions(format) + let size = config.quality.dimensions(width: Int(dimensions.width), height: Int(dimensions.height)) + if configuredSize?.0 != size.0 || configuredSize?.1 != size.1 { + var settings = VideoCodecSettings(videoSize: CGSize(width: size.0, height: size.1), + bitRate: config.quality.bitrateKbps * 1000, + profileLevel: kVTProfileLevel_H264_Baseline_AutoLevel as String, + scalingMode: .letterbox, maxKeyFrameIntervalDuration: 2, + allowFrameReordering: false) + settings.frameInterval = 1 / Double(config.quality.fps) - 0.001 + do { try await stream.setVideoSettings(settings) } catch { throw ScreenKitError.capture } + configuredSize = size + } + await mixer.append(prepared) + } + + func audio(_ buffer: CMSampleBuffer, microphone: Bool) async { + guard !closed, !paused, let config, let mixer else { return } + if microphone { + guard config.audio.includesMicrophone else { return } + } else if config.audio == .microphone { return } + // Silent mode feeds app samples through a muted mixer, rather than capturing the mic. + await mixer.append(buffer, track: microphone ? 1 : 0) + } + + func setPaused(_ value: Bool) async { + guard !closed else { return } + paused = value + status?.phase = value ? .paused : .live + status?.message = value ? "Sharing paused by iOS" : "LIVE · Sending to server" + lastReceivedVideo = Date() + try? persist() + } + + func monitor() async throws { + while !Task.isCancelled && !closed { + try await Task.sleep(for: .seconds(1)) + guard let config, let connection, let stream else { throw ScreenKitError.connection } + guard await connection.connected, await stream.readyState == .publishing else { throw ScreenKitError.connection } + // No automatic reconnect: a new user-approved session is required after failure. + if let control = store?.control(), control.id == config.id { + if control.stop { throw BroadcastStopped() } + if status?.muted != control.muted, let mixer { + var audio = await mixer.audioMixerSettings + audio.isMuted = control.muted || config.audio == .silent + await mixer.setAudioMixerSettings(audio) + status?.muted = control.muted + } + } + if !paused && Date().timeIntervalSince(lastReceivedVideo) > 30 { throw ScreenKitError.capture } + try persist() + } + } + + func finish(failed: Bool) async { + guard !closed else { return } + closed = true + status?.phase = failed ? .failed : .ended + status?.message = failed ? "Stream ended. Check the destination and network, then prepare again." : "Live stream ended." + try? persist() + await mixer?.stopRunning() + _ = try? await stream?.close() + try? await connection?.close() + stream = nil; connection = nil; mixer = nil; config = nil + } + private func persist() throws { + guard var value = status, let store else { return } + value.heartbeat = Date(); status = value + try store.update(value) + } +} diff --git a/ios/BroadcastUpload/BroadcastUpload.entitlements b/ios/BroadcastUpload/BroadcastUpload.entitlements new file mode 100644 index 0000000..1808a3a --- /dev/null +++ b/ios/BroadcastUpload/BroadcastUpload.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + $(SCREENKIT_APP_GROUP) + + keychain-access-groups + + $(SCREENKIT_KEYCHAIN_GROUP) + + + diff --git a/ios/BroadcastUpload/Info.plist b/ios/BroadcastUpload/Info.plist new file mode 100644 index 0000000..b71b90b --- /dev/null +++ b/ios/BroadcastUpload/Info.plist @@ -0,0 +1,33 @@ + + + + + CFBundleDisplayName + ScreenKit Live + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.broadcast-services-upload + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).SampleHandler + RPBroadcastProcessMode + RPBroadcastProcessModeSampleBuffer + + ScreenKitAppGroup + $(SCREENKIT_APP_GROUP) + ScreenKitKeychainGroup + $(SCREENKIT_KEYCHAIN_GROUP) + + diff --git a/ios/BroadcastUpload/PrivacyInfo.xcprivacy b/ios/BroadcastUpload/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..1cbcec2 --- /dev/null +++ b/ios/BroadcastUpload/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/ios/BroadcastUpload/ReplayVideoScaler.swift b/ios/BroadcastUpload/ReplayVideoScaler.swift new file mode 100644 index 0000000..410c72b --- /dev/null +++ b/ios/BroadcastUpload/ReplayVideoScaler.swift @@ -0,0 +1,44 @@ +import CoreImage +import CoreMedia +import ReplayKit + +/// Rotate using ReplayKit's attachment and scale BEFORE queuing encoder frames. +/// A two-buffer pixel pool drops frames under pressure instead of allocating without bound. +final class ReplayVideoScaler { + private let context = CIContext(options: [.cacheIntermediates: false]) + private var pool: CVPixelBufferPool? + private var size = (0, 0) + func convert(_ sample: CMSampleBuffer, quality: StreamQuality) throws -> CMSampleBuffer? { + guard let input = CMSampleBufferGetImageBuffer(sample) else { throw ScreenKitError.capture } + let orientation = (CMGetAttachment(sample, key: RPVideoSampleOrientationKey as CFString, attachmentModeOut: nil) as? NSNumber)?.int32Value ?? 1 + let image = CIImage(cvPixelBuffer: input).oriented(forExifOrientation: orientation) + let wanted = quality.dimensions(width: Int(image.extent.width), height: Int(image.extent.height)) + guard wanted.0 > 0, wanted.1 > 0 else { throw ScreenKitError.capture } + if wanted.0 != size.0 || wanted.1 != size.1 || pool == nil { + pool = nil; size = wanted + let attributes: [String: Any] = [kCVPixelBufferWidthKey as String: size.0, + kCVPixelBufferHeightKey as String: size.1, + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferIOSurfacePropertiesKey as String: [:]] + guard CVPixelBufferPoolCreate(nil, nil, attributes as CFDictionary, &pool) == kCVReturnSuccess else { throw ScreenKitError.capture } + } + guard let pool else { throw ScreenKitError.capture } + var output: CVPixelBuffer? + let result = CVPixelBufferPoolCreatePixelBufferWithAuxAttributes(nil, pool, + [kCVPixelBufferPoolAllocationThresholdKey: 2] as CFDictionary, &output) + if result == kCVReturnWouldExceedAllocationThreshold { return nil } + guard result == kCVReturnSuccess, let output else { throw ScreenKitError.capture } + let translated = image.transformed(by: CGAffineTransform(translationX: -image.extent.minX, y: -image.extent.minY)) + let scaled = translated.transformed(by: CGAffineTransform(scaleX: Double(size.0) / image.extent.width, y: Double(size.1) / image.extent.height)) + context.render(scaled, to: output) + var format: CMVideoFormatDescription? + guard CMVideoFormatDescriptionCreateForImageBuffer(allocator: nil, imageBuffer: output, formatDescriptionOut: &format) == noErr, + let format else { throw ScreenKitError.capture } + var timing = CMSampleTimingInfo(duration: CMSampleBufferGetDuration(sample), + presentationTimeStamp: CMSampleBufferGetPresentationTimeStamp(sample), decodeTimeStamp: .invalid) + var resized: CMSampleBuffer? + guard CMSampleBufferCreateReadyWithImageBuffer(allocator: nil, imageBuffer: output, formatDescription: format, + sampleTiming: &timing, sampleBufferOut: &resized) == noErr else { throw ScreenKitError.capture } + return resized + } +} diff --git a/ios/BroadcastUpload/SampleHandler.swift b/ios/BroadcastUpload/SampleHandler.swift new file mode 100644 index 0000000..56d97a6 --- /dev/null +++ b/ios/BroadcastUpload/SampleHandler.swift @@ -0,0 +1,59 @@ +import ReplayKit +import CoreMedia + +/// ReplayKit owns these callbacks. A bounded channel per media type avoids spawning a task per frame. +private struct ReplaySample: @unchecked Sendable { let buffer: CMSampleBuffer } + +final class SampleHandler: RPBroadcastSampleHandler, @unchecked Sendable { + private let video = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + private let appAudio = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(8)) + private let micAudio = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(8)) + private let pipeline = BroadcastPipeline() + private var job: Task? + private let lock = NSLock() + + override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) { + let task = Task { [self] in + do { + try await pipeline.start() + try Task.checkCancellation() + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { for await sample in self.video.stream { try Task.checkCancellation(); try await self.pipeline.video(sample.buffer) } } + group.addTask { for await sample in self.appAudio.stream { try Task.checkCancellation(); await self.pipeline.audio(sample.buffer, microphone: false) } } + group.addTask { for await sample in self.micAudio.stream { try Task.checkCancellation(); await self.pipeline.audio(sample.buffer, microphone: true) } } + group.addTask { try await self.pipeline.monitor() } + try await group.next() + group.cancelAll() + } + } catch { + let stopped = error is BroadcastStopped + await pipeline.finish(failed: !stopped && !Task.isCancelled) + if !Task.isCancelled { + let message = stopped ? "Live stream ended." : (error as? ScreenKitError)?.localizedDescription ?? ScreenKitError.connection.localizedDescription + finishBroadcastWithError(NSError(domain: "ScreenKit.Broadcast", code: stopped ? 0 : 1, + userInfo: [NSLocalizedDescriptionKey: message])) + } + } + await pipeline.finish(failed: false) + } + lock.lock(); job = task; lock.unlock() + } + override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) { + guard CMSampleBufferDataIsReady(sampleBuffer) else { return } + let sample = ReplaySample(buffer: sampleBuffer) + switch sampleBufferType { + case .video: video.continuation.yield(sample) + case .audioApp: appAudio.continuation.yield(sample) + case .audioMic: micAudio.continuation.yield(sample) + @unknown default: break + } + } + override func broadcastPaused() { Task { await pipeline.setPaused(true) } } + override func broadcastResumed() { Task { await pipeline.setPaused(false) } } + override func broadcastFinished() { + video.continuation.finish(); appAudio.continuation.finish(); micAudio.continuation.finish() + lock.lock(); let task = job; job = nil; lock.unlock() + task?.cancel() + Task { await pipeline.finish(failed: false) } + } +} diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..c433eb3 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,100 @@ +# ScreenKit for iPhone and iPad — 1.0.0 (build 1) + +Native SwiftUI app, iOS **17.0+**, with a ReplayKit Broadcast Upload extension. +This is a separate implementation, not an APK conversion. Android remains at 2.1.0. + +## Implemented + +- **Record:** ReplayKit in-app recording with optional microphone, timer, and MP4 output + in the app's local library. It records **ScreenKit only**, not other apps. +- **Library:** import selected movies from Photos (system picker; no full-library grant) + or Files, share, delete, and optionally save exports to Photos with add-only consent. +- **Studio:** preview, trim/re-encode MP4, extract PNG frames, and export GIFs + (up to 15 seconds, 10 fps, 480 px). All processing stays on device. +- **Go Live:** user-approved screen broadcasting, including other apps, to one + RTMP/RTMPS destination through the bundled ReplayKit extension. Editable YouTube, + Facebook and custom presets; 480p/720p, 24/30 fps, 500–6000 kbps; device/mic/mixed + audio selection; mute/end commands, connection status and elapsed time. +- Rotation and resizing before encoding, bounded sample queues and pixel-buffer pool, + sanitized errors, no payload logging, timeout/disconnection cleanup, no auto-reconnect. + +The first iOS release does **not** claim complete Android parity. No cross-app auto +clicker/macros, floating windows over other apps, color triggers, protected-content +bypass, permission bypass, or system-wide automatic scrolling. No simultaneous local +recording while broadcasting, no pause/resume for local MP4s, and no camera capture UI. +Use iOS Control Center recording for other-app MP4s, then import them into ScreenKit. + +## Open and build on a Mac + +Requirements: Xcode **16+** (Swift 6 compiler), an iOS 17+ SDK/device, and XcodeGen 2.42+. +The app source uses Swift 5 language mode; the pinned transport requires Swift 6 tools. + +```sh +brew install xcodegen +./ios/scripts/generate.sh +open ios/ScreenKit.xcodeproj +``` + +Project settings live in `project.yml`; the generated `.xcodeproj` is ignored. Do not +edit the generated project as the only source of a change. Dependencies are pinned to +HaishinKit 2.1.0 and Logboard 2.5.0. The BSD-3-Clause licenses are bundled in the app. + +### Signing and installation (required for real iPhones) + +1. Choose your Apple development team for **both** targets in Xcode, or set + `DEVELOPMENT_TEAM` in your local XcodeGen settings. +2. Register your own unique app ID and extension ID, e.g. `com.yourname.screenkit` + and `com.yourname.screenkit.broadcast`. Keep the extension's bundle ID under the app's. +3. Update `PRODUCT_BUNDLE_IDENTIFIER`, `SCREENKIT_BROADCAST_ID`, + `SCREENKIT_APP_GROUP`, and `SCREENKIT_KEYCHAIN_GROUP` in `project.yml` to your IDs. +4. Enable the **same App Group** and **Keychain Sharing access group** on both targets + and their provisioning profiles. Keep `$(AppIdentifierPrefix)` in the Keychain group. +5. Regenerate, select an iPhone/iPad, build and run. For TestFlight/App Store, archive, + sign and distribute with your Apple Developer account. + +**There is no universal unsigned IPA download that installs like an Android APK.** +Full broadcast provisioning uses App Groups/Keychain Sharing and requires a team that +supports those capabilities (normally paid Apple Developer Program membership). +Free personal-team signing is not a replacement for the full extension setup. +No signing certificates, profiles, passwords, or Apple account credentials belong in Git. + +## Use Go Live + +1. Create a private/unlisted test broadcast on your streaming platform. +2. Paste its server URL and key separately into ScreenKit. RTMPS is preferred. +3. Choose quality/audio and **Prepare broadcast** after reading the privacy warning. +4. Tap Apple's broadcast icon, choose **ScreenKit Live**, enable its microphone if + your audio mode uses it, and explicitly tap **Start Broadcast**. +5. Check the platform preview. A connected encoder does not mean the stream is public; + the platform may require its own final **Go Live** confirmation. +6. Stop through iOS's recording indicator/Control Center or ScreenKit's End button. + If ScreenKit cannot reach the extension, use the system indicator immediately. + +Do not put stream keys into screenshots, issues, repository files, or console output. +Unlike Android's same-process implementation, iOS extensions cannot receive an +in-memory object from the app. The one-use key therefore goes into a shared Keychain +item using **WhenUnlockedThisDeviceOnly**, and is deleted on consumption/cancellation. +Configuration has a 5-minute validity window. Expired unused items are removed on the +next app refresh or extension attempt; expiry is not a guarantee of background deletion +while the app is terminated. Destination settings/control/status JSON is protected, +excluded from backup, and contains no stream key. Recordings themselves may be backed up. + +The library uses Apple's secure networking for RTMPS; no trust-all callback is installed. +Transport log appenders discard messages, since server responses can contain keys. +Microphone approval is controlled by iOS. Device audio and DRM restrictions are controlled +by source apps and the OS. App/mixed audio relies on ReplayKit app-audio sample delivery; +test silent-source and muted-device behavior on your actual device and destination. + +## Validation + +```sh +swift test --package-path ios/ScreenKitCore +xcodebuild -project ios/ScreenKit.xcodeproj -scheme ScreenKit \ + -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO build +``` + +The `iOS Build` workflow runs core tests and builds the app + extension for device +(unsigned) and simulator. Its simulator ZIP is for a Mac's iOS Simulator only; unsigned +device products are build-verification artifacts, **not installable iPhone downloads**. +ReplayKit broadcasting, microphone/Photos consent, App Group provisioning, extension +memory limits, and real streaming require a signed physical-device test. See TESTING.md. diff --git a/ios/ScreenKit/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/ScreenKit/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..a882f39645156dcad7434db45a95e4d77effce34 GIT binary patch literal 10007 zcmeHtc_5VQ`}g(C3}%F}mZebH$~Lr+XhsW(q?B}$CQE6NDJLyX=FvilP}xGJ6rGZk z7EX(&91*gl7*dU+L?%lKVP<~!NZ;@8egAy_ea}BL+|PAi_x;@0_PIXyob+&aR+QJ4 z2LMHv1JEzM zS;$1n8K=<)t}k8Z2o-%$XP^)L6aNnWdm#Th!@q&>zca(vt%Gv7&t~o)86p+fI?^{- zC5(rY5kI~RF}HGMJFCypIpkm%Re=J-_C6>Oih((9sBabpUM1@y=TxzUJ3fKVoDvLVYBV_BfT zYz~C;uGr{-K zz$*}m+i4Lo6zH}ZA$!Cqu+1aLgrA!P*7}F|nY)8`)JF~cpQ$nh6l2JHGC*e?4l_*_ zQ1H)YIbc@48j==ZytxfbFul!Pod!IM>&DQ1`_$^>_4w6_OEi{_uiPbVV>stOBF9r3 z)qYKd9k*7H>f+zVt$9dVsxjFaTE5vSYuI8u3Q|H7?q919skJ+(ETsp!4Ky$+R3arj zdsps*HZ?by2~U5xfTu0SRzkvj#k8k1Q1kB99UbU-gY3m<`PAFHjeBfy2APox>^uCf z_dI&vH9n&bVhuoZ_Kp#oesb%qYjSg;lnT#WQpJiQSm<&BQzSG=xTE$eNmK;>M7tc9 zWdlY~&2zb-OyYNQR~EUz1|xdTuatB|;786Uq37#K_~E;0;uj)t4e}&u<02}Az49Ni z86lhHI?Dz@*bLxTeQqcz*5gIKmMW0L2#e$k;y5w9Fw4ZW@fS3>cfV&KWI!%x*IR`; z*yRcZx0dB{vze?D%jTd7(=j61D?>UV(yJO22`>tyZ`dte;n+qC@?1ziFlUrZP>>HO zNlK=frC^-g&D*$e-n+BEq)QJkpWHHKa!c@^)uyan?4<8O7Y%jsey;`ceKVBz?OKdt6Kgoevx_!+ zCIH(-rpX6L&GFueAlUa>*VsE4yPDmSzc#ojM;{|huicpWx^V+sy=SxT&4WLkpy137 zQ!|*_e9`*^2r|~hE9c#SwO%{2X5T&P83$~|D_-ls_WABIRSH;M`_@Msk|$<=7H-%# zxaXP*Dx}Iwf%98tx$HjOD?k{27W(W93tgcC86MjnT`i0v5g$JbH}4z#{j)N>TDR>x z1>f0RX5tEcYZIMi8drkX`T$GQ+l8LdKS2r3o%J<=S20r!iaZmi-KY?|tB~-$F?mT) zKpPL9h--ZZh}kq2QzPuY`~&-C6R>93`M4ZeLpK2k-PyuD8iR*sE5q8=TLLM#h~H(h z7&x{E3;?u*!&51+?0^Z0MH-!^L4}S&ZBjyMe);bt$c+T$zaPf~+nT-@mizbmTVM&h zd;GyvAJRH{a3u_d+IZ2tba0Fgg|-y-WDE^BQXupI8VFmGqYT9vB8+8ymSu(!6x{PMNZCo9s$zFCL<~B6UTw(=zu5h`}?|qDd$Rk;kcjYE?QN)4Kz`C->0L24 zqhq2+U3y+^Oz%N9J&@@RJmoo*yzaJ=&7nFT8Gx(CitC>BGq-60iWjzZHuH`F8NdCj z!i3p;oI>1tv|dXz{n+<}^p(xC#*~U2n53u$ZjyvjZZB(=FLtCpmZV&vVIkP*Y$W;KP1b2tL_EWwIF&p~})Q$`*7Qs-f*%VEIg<4yR-YN114MpEQ$H>EE> zR2aevS$alW$`e8z>9Ne=3RD~A*$j*rw0~j8uaDg6ZDm`UU)e!{UA4YSK2?8V3NwN2 zAGSv@-;2&xz+mHTE_tXR3dmI8$7bpjWhGJg>3|3G3nOoMufjLc@Q$&Ky8iqdk-;0* zrB;pArT;Vv9w#H-SEl<43op3Wr_TuqTu*`?zi8P(|Kk|iwIFP8D?6l7iyb-v*ozX& z_Y#_3I_!-k*u8}*>vX%rt?hQh>-1(l0N&(m=A#7|e!W=}@6WR@EqZmw7zB3nW!aOv zYu_#6a(ic-B}`esvI}Ld&HzF~s7&~HU34hAc6krdf6RL1>~k9UR4&5Svdvp!OkK>3 zkxL?_ZAf^Ta58I}*X8sd;SmCTjf4D^aKHANSxDB^`^ zxhn@`km(b<^sY&dQuwr!J`c@d`#W*3qH+U7vYW&s1L0!|KSLQQO_#)hZ4Gk!`0Nm3T4P*@sx1Yr3 zZyShyMgd|~S+qFi1U5flAR0j_;##Q0h0dL9NGPHpV*wP~y%5#T!t(tFqNkGJL_>HF z*PX^eCVNQ*{ben!k_=S=s)`gKS)ojU9Yu+NH^`x}wi-qdWm<(E{3LWXSIijWy;h6V1fE9BBy*l1t-ihmR{E|Y7YoDDm zu@@b-Mw+aIqNa@(8j~m(|Eez3+Ae>@?EMl0dy|6!+ zwAKjlAJwNs!|oWW=R|-}S^gh|Fb|~|MjUp_fjLRmq=E*F=q_e-uQpE|12^^zw{{)5 zt4jc~(`4rSc!$L(1L2WF1Ux>B%TrLp{)0XQ1S&_Ge}5=XT!^tPFo~62YG|_%GT9@vj577xgtHgH2jTWDRK_ldZQo7(?+Y|GR zdn_^G3IH9(ty|{%)rklSa4ZY8!#SHV;Zh{WO0X$pLc#p_?W;ycOC%2lB_EhvQE|EP zh8ASh$KqkzQBU}NCg5+<#Cola{w&GnI5X^1YGB91?W!F=Q-I)RJC@Aq|< ze2{EsE7($ipN!(A;#|F?xV@ZOjkYrb@Qokr#g^7X%>KSV7q1|u0}+M%dHXzPiSbk_ zaLgr2z_~jZFOi6QCCRB`J+q<6kUDWbS^DZtT@KOa4lb6WHp}|acjH52+2sSR?W?fy zw>q95&rWw(7&2IXFt2+Wm2n2(<-W|M2jXHVI*Y>db#L3wcH@uE<`P`gvjl%a0p)A& z1IEr|FlRvuGUu}M4q+?PPo|3wroWAYqd%ev-0X!@Th$NaQm{oi}ypL)uQuPrqSHQQ(|4R?*;!X>8|uyg`@P+_iAs5XR6mwLF>9rJ&*q@lNVep z_j=NvVC*+lq#5Bwklb!O^-6bjZ}1J%hmCJ1F;1azDQfY4{Tn^E>7yNa?o2cyb-jqM z^7F2T(#a2z>$!gQtz4czpJj}au9VPXJLoQt|8$;wXZnQjxQ^lIl10aLTc5)x2TY=uE*zln*DiVmnnS8Ud^tF||NzXbzz@@xJ5m6HgkJ z`RtR=WRd^(=+DEN+&*t$gU>-+7ukZ&-Dl@ebQY^Yb!hy|$2Xf-Ue(!N+yTMA5{ThJ zeTOsG73)q)kGd(hz;1{kZQOqz(Y1#u)=}LGb+Hrs8cwbG(Ecb*gh7;@JHeF;}~P$bFsU$Y3r)W zbkQE&?n81U)WL(e-an|PKNr|cq{6+|xxkgVaPvs%=1W75%Hr$58ydU@(*k2qh8bC=(AOIMJNtcPyIrU@h(P3lZu;b}a|+xm*#+YU!jC z9mqD>8B6eT-P-r1e)@lO4h`!`F{r@l!86uy9qM6`z=P@~_}L59-8KXGNr^$c1e=fQ z&VwNQtlPJAhKMYDQbL|Evt|7D=A)zXY!tS9d_!DtaW)L1IuM-7=f{N85R-u9q|rP_ zw{>nY9(52Qbkw+Y@!R;FRb-|D?v4y-Z+UfBjp0WFMQ}j4H|VL3V>)9f z^n%S19%c+IQ{7GPtFN^0JxyY7M|z_&WPQq7YZa%;5Pu=W*jVawo#Jji6w1^BQ`C0U zWudbi3WIfM#v-=QpU&2J(P*j#*_3UH>4QW&ScZ)2X8CS$>l`82k@X)Rde%4q)rV?|cDw6hHd z)Y&R@nBXY#TPxl{?3TM}Wl6cF69L~^Q#$=uU)!*>gH{1S4Lc;T+DT$00(*+@p@Tih zbME~?tRdD3bA{+QJ?2L&-zo#M`)0B=UsxWUj=1crr8KDCqK!`#hs&+_@$vq9NvcRo zvkfJbc1c#tH;XnC&@NQi8$V0uSP97lHO_475Q?L#6PJg2F~bT7D($4J8v`#Py4aCM z?5!3Nv&X2Z)6)DxIzr^5j}Oz69r#O{}65Ul+1_`VU+#P-D$9iump8BmuHc zqrNQLZV9vQD6ltYm6Y5M+}-os@HM$Wt!hf4Hy<4|{xNMe;8j0VILelT7+6{vBTy-< zSzrV{HIJVoOO8W`ap)0{_We*+gBuk6oj0w44wtCt8FdC~ai#-gY`YntfGL-2{UW%= z4T$f_*hL~-g=Y}+qPn1q45!^>A}O%s;6n^*oAtC@kOl~~0 z@Q99{0Rvlev#A)toNcdKU`?u9cibWWsqZQo%;_&{jORJoRs#F(XO}moAoHTx{NcoC`JwPJqGKK z+||UbDtl}Lhk}q|M4FGR30wAmkmjZjD$i|7!Gd@~3(v64Ic6moPlJhSdT1ch5{KCP z)ni+ZCTZh0)Zcr?^j4tUU$lCHu2+reH7XnOs~nk#F>ij8o*ueES$|*}71vSM6VY?H zJpu~e@%g3zT?qB^Y>c7xx_CN~n2q|gyOo{2{mdbBqx8sY!%?pd4OnVmQl?*0X3Fgh zD2Q8EgDIUR!GWzw^%C~Twn^^5f3~>0e(`mCAe-H>G`sU=O&XUPyHxy$0ZOiM;)3Zn z;ntPozLZ1yWFXzJ9SI++MYkS08*gDs)}a3^CR_4kpf(33q>Xo^BfPuIoFzxQvTo2I z?AC7b)Bs>D>^?1)YhW%!p%~aDYg+;M6 zLHJPx@Ln1}zQa`{{O#TC_)J6a@JKI~R1DPSQ()%YozfA;ro!>3~Ot{NW! zL|ALv5M(PTIQwjggxtXNr$W?xQiyj;NU*3H#% z=w7a>n+0YsI#;c9N9^HWG~zFzAL67g5kvV`eXyYv(07M z3JaAK(M6wzGs2~RcdpVQ;ZaCf$9kk87L{HkoEa;=V*o+!vcMr{GvjD5qXH2Q0Ry=X z7`i6~!lff(FBMdu1L$gsTxLRrAsL=(iJluEauWiqhSb~<$F~A_R-w$PG_ZA~!JREV z(Z=W^Hk$&I1AC&;MQqUtRq#GitmkWp3bG&Y)R!=Qq<6W01EC%jAq5oXT^OF3I=sac z6*@y2>}Iz{qwBp?Cmm>W`@uMlk_fI}QX@v+gzZF98&9b}o6!F@)Bg^RQyBjq$iKfn hLU8B*-{| + + + + CFBundleDisplayName + ScreenKit + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSMicrophoneUsageDescription + Include your voice in recordings when you turn the microphone on. + NSPhotoLibraryAddUsageDescription + Save your selected ScreenKit video or image to Photos. + ScreenKitAppGroup + $(SCREENKIT_APP_GROUP) + ScreenKitBroadcastIdentifier + $(SCREENKIT_BROADCAST_ID) + ScreenKitKeychainGroup + $(SCREENKIT_KEYCHAIN_GROUP) + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIFileSharingEnabled + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/ScreenKit/LiveModel.swift b/ios/ScreenKit/LiveModel.swift new file mode 100644 index 0000000..b799a2e --- /dev/null +++ b/ios/ScreenKit/LiveModel.swift @@ -0,0 +1,133 @@ +import SwiftUI +import ReplayKit + +@MainActor +final class LiveModel: ObservableObject { + @Published var status: BroadcastStatus? + @Published var prepared: BroadcastConfiguration? + @Published var message: String? + @Published var storeAvailable = false + var active: Bool { status?.isActive() == true } + func refresh() { + do { + let store = try BroadcastStore(); storeAvailable = true + store.removeExpiredPreparation() + status = store.status(); prepared = store.prepared() + } catch { storeAvailable = false } + } + func prepare(server: String, key: String, quality: StreamQuality, audio: StreamAudio) -> Bool { + do { + let destination = try StreamDestination(server: server, key: key) + try quality.validate() + let config = BroadcastConfiguration(server: destination.server, quality: quality, audio: audio) + try BroadcastStore().prepare(config, key: destination.key) + refresh(); return true + } catch { message = (error as? ScreenKitError)?.localizedDescription ?? ScreenKitError.credential.localizedDescription; return false } + } + func cancelPreparation() { (try? BroadcastStore())?.clearPrepared(); refresh() } + func control(stop: Bool = false) { + guard let status, status.isActive() else { return } + do { + try BroadcastStore().command(.init(id: status.id, muted: stop ? status.muted : !status.muted, stop: stop)) + if stop { message = "Stop requested. You can also use iOS's red recording indicator to stop immediately." } + } catch { message = "Could not reach the broadcast. Stop using iOS's recording indicator." } + } +} + +/// This is Apple's real picker, not a hidden-button hack. The user explicitly chooses Start Broadcast. +struct BroadcastPicker: UIViewRepresentable { + let microphone: Bool + func makeUIView(context: Context) -> RPSystemBroadcastPickerView { + let view = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 60, height: 60)) + view.preferredExtension = Bundle.main.object(forInfoDictionaryKey: "ScreenKitBroadcastIdentifier") as? String + view.showsMicrophoneButton = microphone + view.tintColor = .systemRed + view.accessibilityLabel = "Open Apple's Start Broadcast controls" + return view + } + func updateUIView(_ view: RPSystemBroadcastPickerView, context: Context) { view.showsMicrophoneButton = microphone } +} + +struct LiveView: View { + @ObservedObject var recording: RecordingModel + @StateObject private var model = LiveModel() + @State private var server = "rtmps://a.rtmps.youtube.com/live2" + @State private var key = "" + @State private var platform = "YouTube" + @State private var quality = StreamQuality() + @State private var audio = StreamAudio.app + @State private var consent = false + var body: some View { + NavigationStack { + Form { + Section { + Label("Your screen, on air.", systemImage: "dot.radiowaves.left.and.right").font(.title2.bold()) + Text("Broadcast your screen with ReplayKit. Nothing starts until you confirm in Apple's broadcast controls.") + } + if model.active, let status = model.status { + Section("Broadcast") { + Label(status.message, systemImage: "record.circle").foregroundStyle(.red) + if let started = status.startedAt { Text(started, style: .timer).monospacedDigit() } + Text(status.muted ? "Outgoing audio muted" : "Using your selected audio sources") + Button(status.muted ? "Unmute audio" : "Mute audio") { model.control() } + Button("End broadcast", role: .destructive) { model.control(stop: true) } + Text("Server acceptance does not mean your broadcast is publicly live. Check your platform dashboard.").font(.footnote) + } + } else if let config = model.prepared { + Section("Ready for Apple's approval") { + Text("Tap the broadcast icon below, choose ScreenKit Live, then Start Broadcast. Setup expires after 5 minutes.") + HStack { Spacer(); BroadcastPicker(microphone: config.audio.includesMicrophone).frame(width: 60, height: 60); Spacer() } + if config.audio.includesMicrophone { Text("Turn Microphone On in Apple's controls to include your voice. ScreenKit cannot turn it on for you.").font(.footnote) } + Button("Cancel and erase prepared key", role: .destructive) { model.cancelPreparation() } + } + } else { + if let status = model.status { + Section { + Text(status.isActive() ? status.message : ([.live, .connecting, .paused].contains(status.phase) ? "Broadcast status is stale. Check iOS's recording indicator before starting again." : status.message)) + } + } + Section("Destination") { + Picker("Platform", selection: $platform) { ForEach(["YouTube", "Facebook", "Custom"], id: \.self) { Text($0) } } + .onChange(of: platform) { _, value in + key = "" + server = value == "YouTube" ? "rtmps://a.rtmps.youtube.com/live2" : value == "Facebook" ? "rtmps://live-api-s.facebook.com:443/rtmp" : "" + } + TextField("Server URL", text: $server).keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled() + SecureField("Stream key", text: $key).textInputAutocapitalization(.never).autocorrectionDisabled().privacySensitive() + Text("Paste the exact values from your live dashboard. Your key is transferred once through device-only Keychain storage and erased when the extension consumes it. Unused setup expires after 5 minutes.").font(.footnote) + if server.hasPrefix("rtmp://") { Text("RTMP is unencrypted. Prefer RTMPS to protect your stream and key in transit.").foregroundStyle(.orange) } + } + Section("Stream quality") { + Picker("Resolution", selection: $quality.shortEdge) { Text("480p").tag(480); Text("720p").tag(720) } + Picker("Frame rate", selection: $quality.fps) { Text("24 fps").tag(24); Text("30 fps").tag(30) } + Stepper("Video bitrate: \(quality.bitrateKbps) kbps", value: $quality.bitrateKbps, in: 500...6000, step: 250) + Picker("Audio", selection: $audio) { ForEach(StreamAudio.allCases, id: \.self) { Text($0.rawValue).tag($0) } } + Text("Resolution preserves aspect ratio, capped at a 1280-pixel long edge to respect the extension's memory limit. App audio is only available when iOS and the source app permit it.").font(.footnote) + } + Section { + Button("Prepare broadcast") { consent = true } + .disabled(key.isEmpty || recording.recording || recording.busy || !model.storeAvailable) + if recording.recording { Text("Stop the in-app recording before broadcasting.") } + if !model.storeAvailable { Text(ScreenKitError.signing.localizedDescription).foregroundStyle(.orange) } + } + } + Section("Know before you go live") { + Text("Visible notifications and personal information may be broadcast. Enable Focus first. DRM and other protected screens cannot be captured. No floating controls or auto-clicking over other apps.") + Text("One destination, no simultaneous local recording. Failed connections do not silently restart. Use the iOS recording indicator to stop even when ScreenKit is closed.") + } + } + .navigationTitle("Go Live") + .task { + while !Task.isCancelled { model.refresh(); try? await Task.sleep(for: .seconds(1)) } + } + .confirmationDialog("Share your screen live?", isPresented: $consent, titleVisibility: .visible) { + Button("Prepare secure broadcast setup") { + if model.prepare(server: server, key: key, quality: quality, audio: audio) { key = "" } + } + } message: { Text("Your screen and selected audio can be sent to your streaming destination. Review your platform's privacy setting and hide personal information first.") } + .alert("ScreenKit Live", isPresented: Binding(get: { model.message != nil }, set: { if !$0 { model.message = nil } })) { + Button("OK") { model.message = nil } + } message: { Text(model.message ?? "") } + } + } +} diff --git a/ios/ScreenKit/MediaLibrary.swift b/ios/ScreenKit/MediaLibrary.swift new file mode 100644 index 0000000..7e88581 --- /dev/null +++ b/ios/ScreenKit/MediaLibrary.swift @@ -0,0 +1,64 @@ +import Foundation +import Photos +import UniformTypeIdentifiers + +struct LibraryItem: Identifiable, Hashable { + let url: URL + let createdAt: Date + var id: String { url.lastPathComponent } + var isVideo: Bool { ["mp4", "mov", "m4v"].contains(url.pathExtension.lowercased()) } + var title: String { url.deletingPathExtension().lastPathComponent.components(separatedBy: "_").first ?? "Capture" } +} + +@MainActor +final class MediaLibrary: ObservableObject { + @Published private(set) var items: [LibraryItem] = [] + @Published var message: String? + let directory: URL + init() { + directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("Captures", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + refresh() + } + func output(_ prefix: String, ext: String = "mp4") -> URL { + directory.appendingPathComponent("\(prefix)_\(UUID().uuidString).\(ext)") + } + func refresh() { + let urls = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.creationDateKey], options: [.skipsHiddenFiles])) ?? [] + items = urls.filter { ["mp4", "mov", "m4v", "png", "gif"].contains($0.pathExtension.lowercased()) }.map { + LibraryItem(url: $0, createdAt: (try? $0.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? .distantPast) + }.sorted { $0.createdAt > $1.createdAt } + } + func importVideo(_ source: URL) async { + let destination = output("Imported", ext: source.pathExtension.isEmpty ? "mov" : source.pathExtension.lowercased()) + do { + try await Task.detached(priority: .userInitiated) { + let access = source.startAccessingSecurityScopedResource() + defer { if access { source.stopAccessingSecurityScopedResource() } } + let size = try source.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard size > 0, size <= 1_000_000_000 else { throw ScreenKitError.size } + try FileManager.default.copyItem(at: source, to: destination) + do { _ = try await VideoTools.duration(destination) } + catch { try? FileManager.default.removeItem(at: destination); throw error } + }.value + refresh(); message = "Video imported." + } catch { message = (error as? ScreenKitError)?.localizedDescription ?? "Could not import this video. Check storage and file access." } + } + func delete(_ item: LibraryItem) { + do { try FileManager.default.removeItem(at: item.url); refresh() } + catch { message = "Could not delete this capture." } + } + func saveToPhotos(_ item: LibraryItem) async { + let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly) + guard status == .authorized || status == .limited else { + message = "Photos access was not granted. You can still use Share → Save to Files."; return + } + do { + try await PHPhotoLibrary.shared().performChanges { + if item.isVideo { PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: item.url) } + else { PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: item.url) } + } + message = "Saved to Photos." + } catch { message = "Could not save this capture to Photos." } + } +} diff --git a/ios/ScreenKit/PhotoVideoPicker.swift b/ios/ScreenKit/PhotoVideoPicker.swift new file mode 100644 index 0000000..1df00b4 --- /dev/null +++ b/ios/ScreenKit/PhotoVideoPicker.swift @@ -0,0 +1,33 @@ +import SwiftUI +import PhotosUI +import UniformTypeIdentifiers + +/// The system picker grants access only to the selected movie; full Photos-library access isn't requested. +struct PhotoVideoPicker: UIViewControllerRepresentable { + let onPicked: (URL?) -> Void + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration(); config.filter = .videos; config.selectionLimit = 1 + config.preferredAssetRepresentationMode = .current + let picker = PHPickerViewController(configuration: config); picker.delegate = context.coordinator + return picker + } + func updateUIViewController(_ controller: PHPickerViewController, context: Context) {} + func makeCoordinator() -> Coordinator { Coordinator(onPicked) } + final class Coordinator: NSObject, PHPickerViewControllerDelegate { + let onPicked: (URL?) -> Void + init(_ onPicked: @escaping (URL?) -> Void) { self.onPicked = onPicked } + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + guard let provider = results.first?.itemProvider else { onPicked(nil); return } + provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, _ in + var copied: URL? + if let url { + let target = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension(url.pathExtension) + if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize, size <= 1_000_000_000 { + do { try FileManager.default.copyItem(at: url, to: target); copied = target } catch { } + } + } + DispatchQueue.main.async { self.onPicked(copied) } + } + } + } +} diff --git a/ios/ScreenKit/PrivacyInfo.xcprivacy b/ios/ScreenKit/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..1cbcec2 --- /dev/null +++ b/ios/ScreenKit/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/ios/ScreenKit/RecordingModel.swift b/ios/ScreenKit/RecordingModel.swift new file mode 100644 index 0000000..884566e --- /dev/null +++ b/ios/ScreenKit/RecordingModel.swift @@ -0,0 +1,46 @@ +import ReplayKit + +@MainActor +final class RecordingModel: NSObject, ObservableObject, RPScreenRecorderDelegate { + @Published private(set) var recording = false + @Published private(set) var busy = false + @Published var microphone = false + @Published var message: String? + @Published private(set) var startedAt: Date? + private let recorder = RPScreenRecorder.shared() + override init() { super.init(); recorder.delegate = self } + func start() { + guard !busy, !recording else { return } + guard recorder.isAvailable else { message = ScreenKitError.capture.localizedDescription; return } + guard (try? BroadcastStore().status()?.isActive()) != true else { message = "End your broadcast before recording."; return } + busy = true + recorder.isMicrophoneEnabled = microphone + recorder.startRecording { [weak self] error in + Task { @MainActor in + guard let self else { return } + self.busy = false + self.recording = error == nil + self.startedAt = error == nil ? Date() : nil + if error != nil { self.message = "Recording could not start or permission was declined." } + } + } + } + func stop(library: MediaLibrary) { + guard recording, !busy else { return } + busy = true + let output = library.output("Recording") + recorder.stopRecording(withOutput: output) { [weak self] error in + Task { @MainActor in + self?.busy = false; self?.recording = false; self?.startedAt = nil + if error == nil { library.refresh(); self?.message = "Recording saved in your ScreenKit library." } + else { try? FileManager.default.removeItem(at: output); self?.message = "The recording could not be saved." } + } + } + } + nonisolated func screenRecorder(_ screenRecorder: RPScreenRecorder, didStopRecordingWith previewViewController: RPPreviewViewController?, error: Error?) { + Task { @MainActor in + self.recording = false; self.busy = false; self.startedAt = nil + if error != nil { self.message = "iOS interrupted recording. Start a new recording when ready." } + } + } +} diff --git a/ios/ScreenKit/ScreenKit.entitlements b/ios/ScreenKit/ScreenKit.entitlements new file mode 100644 index 0000000..1808a3a --- /dev/null +++ b/ios/ScreenKit/ScreenKit.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + $(SCREENKIT_APP_GROUP) + + keychain-access-groups + + $(SCREENKIT_KEYCHAIN_GROUP) + + + diff --git a/ios/ScreenKit/ScreenKitApp.swift b/ios/ScreenKit/ScreenKitApp.swift new file mode 100644 index 0000000..b1a5d48 --- /dev/null +++ b/ios/ScreenKit/ScreenKitApp.swift @@ -0,0 +1,139 @@ +import SwiftUI +import AVKit +import UniformTypeIdentifiers + +@main +struct ScreenKitApp: App { + @StateObject private var library = MediaLibrary() + @StateObject private var recording = RecordingModel() + var body: some Scene { + WindowGroup { + TabView { + RecordView(recording: recording, library: library).tabItem { Label("Record", systemImage: "record.circle") } + LiveView(recording: recording).tabItem { Label("Go Live", systemImage: "dot.radiowaves.left.and.right") } + LibraryView(library: library).tabItem { Label("Library", systemImage: "rectangle.stack") } + AboutView().tabItem { Label("About", systemImage: "info.circle") } + } + .tint(Color(red: 0.72, green: 0.95, blue: 0.43)) + .preferredColorScheme(.dark) + .alert("ScreenKit", isPresented: Binding(get: { library.message != nil }, set: { if !$0 { library.message = nil } })) { + Button("OK") { library.message = nil } + } message: { Text(library.message ?? "") } + } + } +} + +private struct RecordView: View { + @ObservedObject var recording: RecordingModel + @ObservedObject var library: MediaLibrary + @State private var script = "Welcome to ScreenKit. Record a walkthrough, read your notes, or share your screen live." + var body: some View { + NavigationStack { + Form { + Section { + Label("Create. Capture. Go live.", systemImage: "viewfinder").font(.title2.bold()) + Text("ScreenKit for iPhone & iPad").foregroundStyle(.secondary) + } + Section("Record inside ScreenKit") { + Toggle("Include microphone", isOn: $recording.microphone).disabled(recording.recording || recording.busy) + if let date = recording.startedAt { Label { Text(date, style: .timer).monospacedDigit() } icon: { Image(systemName: "record.circle").foregroundStyle(.red) } } + Button(recording.recording ? "Stop and save recording" : "Start in-app recording", systemImage: recording.recording ? "stop.fill" : "record.circle") { + if recording.recording { recording.stop(library: library) } else { recording.start() } + }.disabled(recording.busy) + if recording.busy { ProgressView("Waiting for iOS…") } + Text("ReplayKit's in-app recorder records ScreenKit, not other apps. iOS controls the permission prompt. Stay in the app until you stop and save.").font(.footnote) + } + Section("Record other apps") { + Text("Use iOS Control Center → Screen Recording to record other apps to Photos, then import the video into ScreenKit's Library. To broadcast other apps instead, use Go Live.") + } + Section("Your recording notes") { + TextEditor(text: $script).frame(minHeight: 180) + Text("These notes are visible in the recording. They are not a private overlay.").font(.footnote) + } + } + .navigationTitle("ScreenKit") + .alert("Screen recording", isPresented: Binding(get: { recording.message != nil }, set: { if !$0 { recording.message = nil } })) { + Button("OK") { recording.message = nil } + } message: { Text(recording.message ?? "") } + } + } +} + +private struct LibraryView: View { + @ObservedObject var library: MediaLibrary + @State private var importingFiles = false + @State private var importingPhotos = false + @State private var importing = false + @State private var editor: LibraryItem? + @State private var deleting: LibraryItem? + var body: some View { + NavigationStack { + List { + Section { + Button("Import from Photos", systemImage: "photo.on.rectangle") { importingPhotos = true }.disabled(importing) + Button("Import from Files", systemImage: "folder") { importingFiles = true }.disabled(importing) + if importing { ProgressView("Importing video…") } + } + Section("Captures & exports") { + if library.items.isEmpty { ContentUnavailableView("Your studio starts here", systemImage: "film", description: Text("Record inside ScreenKit or import a video to trim it, make a GIF, or save a frame.")) } + ForEach(library.items) { item in + VStack(alignment: .leading, spacing: 10) { + Label(item.title, systemImage: item.isVideo ? "film" : "photo").font(.headline) + Text(item.createdAt, style: .date).font(.caption).foregroundStyle(.secondary) + HStack { + if item.isVideo { Button("Studio") { editor = item }.buttonStyle(.bordered) } + ShareLink(item: item.url) { Label("Share", systemImage: "square.and.arrow.up") }.buttonStyle(.bordered) + Menu { + Button("Save to Photos") { Task { await library.saveToPhotos(item) } } + Button("Delete", role: .destructive) { deleting = item } + } label: { Image(systemName: "ellipsis.circle").font(.title3) } + } + }.padding(.vertical, 6) + } + } + } + .navigationTitle("Your Library") + .refreshable { library.refresh() } + .fileImporter(isPresented: $importingFiles, allowedContentTypes: [.movie]) { result in + switch result { + case .success(let url): importing = true; Task { await library.importVideo(url); importing = false } + case .failure: library.message = "Import cancelled or the file is unavailable." + } + } + .sheet(isPresented: $importingPhotos) { + PhotoVideoPicker { url in + importingPhotos = false + guard let url else { return } + importing = true + Task { await library.importVideo(url); try? FileManager.default.removeItem(at: url); importing = false } + } + } + .sheet(item: $editor) { StudioView(item: $0, library: library) } + .confirmationDialog("Delete this capture from ScreenKit?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } })) { + Button("Delete", role: .destructive) { if let item = deleting { library.delete(item) }; deleting = nil } + } + } + } +} + +private struct AboutView: View { + var body: some View { + NavigationStack { + Form { + Section("ScreenKit iOS 1.0.0") { Text("A native SwiftUI companion to ScreenKit for Android. Editing runs locally on your device. No advertising or analytics SDK is included.") } + Section("Privacy") { + Text("Only the destination you choose receives your live screen/audio. Enable Focus and hide private information before broadcasting. Stream keys use one-use, device-only shared Keychain storage, never logs or analytics.") + Text("Recordings and exports remain in ScreenKit's Documents folder unless you share or save them to Photos. They may be included in your device backup. Uninstalling ScreenKit removes its local library.") + } + Section("iOS boundaries") { + Text("No auto-clicking or macros in other apps, system-wide floating controls, permission bypass, or protected-content capture. No silent background recording. Public broadcasts depend on your platform's account eligibility and dashboard controls.") + } + Section("Open source") { + Link("ScreenKit source & support", destination: URL(string: "https://github.com/sharthak18/screenkit")!) + Text("Broadcast transport: HaishinKit.swift 2.1.0 and Logboard 2.5.0 (BSD-3-Clause). Notices are included with this app.") + if let url = Bundle.main.url(forResource: "ThirdPartyNotices", withExtension: "txt"), let text = try? String(contentsOf: url, encoding: .utf8) { Text(text).font(.caption) } + } + }.navigationTitle("About") + } + } +} diff --git a/ios/ScreenKit/StudioView.swift b/ios/ScreenKit/StudioView.swift new file mode 100644 index 0000000..cbedb2b --- /dev/null +++ b/ios/ScreenKit/StudioView.swift @@ -0,0 +1,78 @@ +import SwiftUI +import AVKit + +@MainActor +final class StudioModel: ObservableObject { + @Published var busy = false + @Published var message: String? + private var job: Task? + func run(library: MediaLibrary, ext: String, prefix: String, operation: @escaping @Sendable (URL) async throws -> Void) { + guard !busy else { return } + busy = true + let output = library.output(prefix, ext: ext) + job = Task { + let work = Task.detached(priority: .userInitiated) { try await operation(output) } + do { + try await withTaskCancellationHandler { try await work.value } onCancel: { work.cancel() } + try Task.checkCancellation() + library.refresh(); message = "Saved to your ScreenKit library." + } catch { + try? FileManager.default.removeItem(at: output) + if !Task.isCancelled { message = (error as? ScreenKitError)?.localizedDescription ?? ScreenKitError.media.localizedDescription } + } + busy = false + } + } + func cancel() { job?.cancel() } +} + +struct StudioView: View { + let item: LibraryItem + @ObservedObject var library: MediaLibrary + @StateObject private var model = StudioModel() + @State private var duration = 0.0 + @State private var start = "0" + @State private var end = "10" + @State private var frameTime = "0" + @State private var player: AVPlayer? + var body: some View { + NavigationStack { + Form { + Section { + if let player { VideoPlayer(player: player).frame(height: 220) } + Text("Duration: \(duration, specifier: "%.1f") seconds") + } + Section("Trim or GIF range · seconds") { + TextField("Start", text: $start).keyboardType(.decimalPad) + TextField("End", text: $end).keyboardType(.decimalPad) + Button("Export trimmed MP4") { + let a = Double(start) ?? .nan, b = Double(end) ?? .nan, source = item.url + model.run(library: library, ext: "mp4", prefix: "Trimmed") { try await VideoTools.trim(source, output: $0, start: a, end: b) } + } + Button("Make animated GIF") { + let a = Double(start) ?? .nan, b = Double(end) ?? .nan, source = item.url + model.run(library: library, ext: "gif", prefix: "GIF") { try await VideoTools.gif(source, output: $0, start: a, end: b) } + } + Text("GIF: up to 15 seconds, 10 fps, maximum 480 px. MP4 trimming re-encodes for compatibility and may take time.").font(.footnote) + }.disabled(model.busy || duration <= 0) + Section("Extract a still frame") { + TextField("Time in seconds", text: $frameTime).keyboardType(.decimalPad) + Button("Save PNG frame") { + let time = Double(frameTime) ?? .nan, source = item.url + model.run(library: library, ext: "png", prefix: "Frame") { try await VideoTools.frame(source, output: $0, seconds: time) } + } + }.disabled(model.busy || duration <= 0) + if model.busy { Section { ProgressView("Processing on device…"); Button("Cancel", role: .cancel) { model.cancel() } } } + } + .navigationTitle("Video Studio") + .task { + do { duration = try await VideoTools.duration(item.url); end = String(format: "%.2f", min(duration, 15)); player = AVPlayer(url: item.url) } + catch { model.message = ScreenKitError.media.localizedDescription } + } + .onDisappear { player?.pause(); model.cancel() } + .alert("Video Studio", isPresented: Binding(get: { model.message != nil }, set: { if !$0 { model.message = nil } })) { + Button("OK") { model.message = nil } + } message: { Text(model.message ?? "") } + } + } +} diff --git a/ios/ScreenKit/ThirdPartyNotices.txt b/ios/ScreenKit/ThirdPartyNotices.txt new file mode 100644 index 0000000..0a12b81 --- /dev/null +++ b/ios/ScreenKit/ThirdPartyNotices.txt @@ -0,0 +1,66 @@ +HaishinKit.swift 2.1.0 +https://github.com/HaishinKit/HaishinKit.swift + +BSD 3-Clause License + +Copyright (c) 2015, shogo4405 +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +Logboard 2.5.0 +https://github.com/shogo4405/Logboard + +BSD 3-Clause License + +Copyright (c) 2017, shogo4405 +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/ios/ScreenKit/VideoTools.swift b/ios/ScreenKit/VideoTools.swift new file mode 100644 index 0000000..d822f48 --- /dev/null +++ b/ios/ScreenKit/VideoTools.swift @@ -0,0 +1,61 @@ +import AVFoundation +import ImageIO +import UniformTypeIdentifiers + +/// Work is performed off the UI thread. All outputs are app-owned files, removed on failure. +enum VideoTools { + static func duration(_ url: URL) async throws -> Double { + let asset = AVURLAsset(url: url) + let duration = try await asset.load(.duration).seconds + let tracks = try await asset.loadTracks(withMediaType: .video) + guard duration.isFinite, duration > 0, !tracks.isEmpty else { throw ScreenKitError.media } + return duration + } + static func trim(_ source: URL, output: URL, start: Double, end: Double) async throws { + let asset = AVURLAsset(url: source) + try ClipRange.validate(start: start, end: end, duration: try await duration(source)) + guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else { throw ScreenKitError.media } + exporter.outputURL = output + exporter.outputFileType = .mp4 + exporter.shouldOptimizeForNetworkUse = true + exporter.timeRange = CMTimeRange(start: CMTime(seconds: start, preferredTimescale: 600), end: CMTime(seconds: end, preferredTimescale: 600)) + try await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + exporter.exportAsynchronously { continuation.resume() } + } + } onCancel: { exporter.cancelExport() } + guard exporter.status == .completed, !Task.isCancelled else { + try? FileManager.default.removeItem(at: output); throw ScreenKitError.media + } + } + static func frame(_ source: URL, output: URL, seconds: Double) async throws { + let length = try await duration(source) + guard seconds.isFinite, seconds >= 0, seconds < length else { throw ScreenKitError.range } + let generator = AVAssetImageGenerator(asset: AVURLAsset(url: source)) + generator.appliesPreferredTrackTransform = true + let image = try generator.copyCGImage(at: CMTime(seconds: seconds, preferredTimescale: 600), actualTime: nil) + guard let destination = CGImageDestinationCreateWithURL(output as CFURL, UTType.png.identifier as CFString, 1, nil) else { throw ScreenKitError.media } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { try? FileManager.default.removeItem(at: output); throw ScreenKitError.media } + } + static func gif(_ source: URL, output: URL, start: Double, end: Double) async throws { + try ClipRange.validate(start: start, end: end, duration: try await duration(source)) + guard end - start <= 15 else { throw ScreenKitError.range } + let frames = max(1, Int(ceil((end - start) * 10))) + let generator = AVAssetImageGenerator(asset: AVURLAsset(url: source)) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: 480, height: 480) + guard let destination = CGImageDestinationCreateWithURL(output as CFURL, UTType.gif.identifier as CFString, frames, nil) else { throw ScreenKitError.media } + CGImageDestinationSetProperties(destination, [kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFLoopCount: 0]] as CFDictionary) + do { + for index in 0.. String { + var value = self + while value.hasSuffix("/") { value.removeLast() } + return value + } +} + +public enum StreamAudio: String, Codable, CaseIterable, Sendable { + case app = "Device audio", microphone = "Microphone", mixed = "Device + microphone", silent = "No audio" + public var includesMicrophone: Bool { self == .microphone || self == .mixed } +} + +public struct StreamQuality: Codable, Equatable, Sendable { + public var shortEdge: Int + public var fps: Int + public var bitrateKbps: Int + public init(shortEdge: Int = 720, fps: Int = 30, bitrateKbps: Int = 2500) { + self.shortEdge = shortEdge; self.fps = fps; self.bitrateKbps = bitrateKbps + } + public func validate() throws { + guard [480, 720].contains(shortEdge), [24, 30].contains(fps), (500...6000).contains(bitrateKbps) else { throw ScreenKitError.quality } + } + public func dimensions(width: Int, height: Int) -> (Int, Int) { + guard width > 0, height > 0 else { return (0, 0) } + let scale = min(1, Double(shortEdge) / Double(min(width, height)), 1280 / Double(max(width, height))) + return (max(2, Int(Double(width) * scale) & ~1), max(2, Int(Double(height) * scale) & ~1)) + } +} + +/// Contains no key. The one-use key is held in a shared, device-only Keychain item. +public struct BroadcastConfiguration: Codable, Sendable { + public let id: UUID + public let server: String + public let quality: StreamQuality + public let audio: StreamAudio + public let createdAt: Date + public init(id: UUID = UUID(), server: String, quality: StreamQuality, audio: StreamAudio, createdAt: Date = Date()) { + self.id = id; self.server = server; self.quality = quality; self.audio = audio; self.createdAt = createdAt + } + public func validate(now: Date = Date()) throws { + guard now.timeIntervalSince(createdAt) >= -5, now.timeIntervalSince(createdAt) < 300 else { throw ScreenKitError.expired } + try quality.validate() + } +} + +public struct BroadcastStatus: Codable, Sendable { + public enum Phase: String, Codable, Sendable { case connecting, live, paused, ended, failed } + public var id: UUID + public var phase: Phase + public var message: String + public var heartbeat: Date + public var startedAt: Date? + public var muted: Bool + public init(id: UUID, phase: Phase, message: String, heartbeat: Date = Date(), startedAt: Date? = nil, muted: Bool = false) { + self.id = id; self.phase = phase; self.message = message; self.heartbeat = heartbeat; self.startedAt = startedAt; self.muted = muted + } + public func isActive(now: Date = Date()) -> Bool { + [.connecting, .live, .paused].contains(phase) && (0..<15).contains(now.timeIntervalSince(heartbeat)) + } +} +public struct BroadcastControl: Codable, Sendable { + public let id: UUID + public let muted: Bool + public let stop: Bool + public init(id: UUID, muted: Bool, stop: Bool = false) { self.id = id; self.muted = muted; self.stop = stop } +} +public enum ClipRange { + public static func validate(start: Double, end: Double, duration: Double) throws { + guard start.isFinite, end.isFinite, duration.isFinite, start >= 0, end > start, end <= duration + 0.01 else { throw ScreenKitError.range } + } +} diff --git a/ios/ScreenKitCore/Tests/ScreenKitCoreTests/CoreTests.swift b/ios/ScreenKitCore/Tests/ScreenKitCoreTests/CoreTests.swift new file mode 100644 index 0000000..c8b796c --- /dev/null +++ b/ios/ScreenKitCore/Tests/ScreenKitCoreTests/CoreTests.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import ScreenKitCore + +final class CoreTests: XCTestCase { + func testYouTubeDestinationAndWhitespace() throws { + let value = try StreamDestination(server: " rtmps://a.rtmps.youtube.com/live2/ ", key: " abc ") + XCTAssertEqual(value.server, "rtmps://a.rtmps.youtube.com/live2") + XCTAssertEqual(value.key, "abc") + XCTAssertFalse(value.description.contains("abc")) + } + func testFacebookQueryKeyPreserved() throws { + let key = "123?s_bl=1&token=a%2Bb%3D" + let value = try StreamDestination(server: "rtmps://live-api-s.facebook.com:443/rtmp", key: key) + XCTAssertEqual(value.key, key) + } + func testInvalidServers() { + for server in ["https://host/live", "rtmp:///live", "rtmp://host", "rtmp://host/", "rtmp://host:0/live", "rtmp://host:65536/live", "rtmps://user:pass@host/live", "rtmps://host/live?q=key", "rtmps://host/live#x", "rtmp://bad host/live"] { + XCTAssertThrowsError(try StreamDestination(server: server, key: "private-key")) { error in + XCTAssertFalse(error.localizedDescription.contains("private-key")) + } + } + } + func testInvalidKeys() { + for key in ["", "abc def", "abc\ndef", "rtmp://host/key", "/key", "key#fragment", String(repeating: "x", count: 4097)] { + XCTAssertThrowsError(try StreamDestination(server: "rtmps://host/live", key: key)) + } + } + func testQualityBounds() throws { + try StreamQuality().validate() + XCTAssertThrowsError(try StreamQuality(shortEdge: 1080).validate()) + XCTAssertThrowsError(try StreamQuality(fps: 60).validate()) + XCTAssertThrowsError(try StreamQuality(bitrateKbps: 499).validate()) + XCTAssertThrowsError(try StreamQuality(bitrateKbps: 6001).validate()) + } + func testDimensionsPreserveAspectAndDoNotUpscale() { + let quality = StreamQuality() + let portrait = quality.dimensions(width: 1080, height: 1920) + XCTAssertEqual(portrait.0, 720); XCTAssertEqual(portrait.1, 1280) + let landscape = quality.dimensions(width: 1920, height: 1080) + XCTAssertEqual(landscape.0, 1280); XCTAssertEqual(landscape.1, 720) + XCTAssertEqual(quality.dimensions(width: 320, height: 480).0, 320) + XCTAssertEqual(quality.dimensions(width: 1179, height: 2556).0 % 2, 0) + XCTAssertEqual(quality.dimensions(width: 0, height: 0).0, 0) + } + func testPreparedConfigurationExpiresAndRejectsClockRollback() throws { + let date = Date(timeIntervalSince1970: 1000) + let config = BroadcastConfiguration(server: "rtmps://host/live", quality: .init(), audio: .app, createdAt: date) + try config.validate(now: date.addingTimeInterval(299)) + XCTAssertThrowsError(try config.validate(now: date.addingTimeInterval(300))) + XCTAssertThrowsError(try config.validate(now: date.addingTimeInterval(-30))) + } + func testSharedConfigurationContainsNoCredentialField() throws { + let config = BroadcastConfiguration(server: "rtmps://host/live", quality: .init(), audio: .mixed) + let text = String(decoding: try JSONEncoder().encode(config), as: UTF8.self) + XCTAssertFalse(text.lowercased().contains("key")) + let decoded = try JSONDecoder().decode(BroadcastConfiguration.self, from: Data(text.utf8)) + XCTAssertEqual(decoded.id, config.id) + } + func testHeartbeatAndTerminalStatuses() { + let date = Date(timeIntervalSince1970: 1000) + var status = BroadcastStatus(id: UUID(), phase: .live, message: "LIVE", heartbeat: date) + XCTAssertTrue(status.isActive(now: date.addingTimeInterval(10))) + XCTAssertFalse(status.isActive(now: date.addingTimeInterval(15))) + XCTAssertFalse(status.isActive(now: date.addingTimeInterval(-1))) + status.phase = .ended + XCTAssertFalse(status.isActive(now: date)) + } + func testClipRangesRejectNonFiniteAndReversedTimes() throws { + try ClipRange.validate(start: 1, end: 5, duration: 10) + for range in [(-1.0, 2.0), (2, 1), (0, 11), (.nan, 3), (0, .infinity), (0, 0)] { + XCTAssertThrowsError(try ClipRange.validate(start: range.0, end: range.1, duration: 10)) + } + } +} diff --git a/ios/Shared/BroadcastStore.swift b/ios/Shared/BroadcastStore.swift new file mode 100644 index 0000000..98b2b14 --- /dev/null +++ b/ios/Shared/BroadcastStore.swift @@ -0,0 +1,66 @@ +import Foundation +import Security + +/// The app and extension are separate processes. Share only protected files and a one-use Keychain item. +struct BroadcastStore { + private let root: URL + private let keychainGroup: String + init(bundle: Bundle = .main) throws { + guard let group = bundle.object(forInfoDictionaryKey: "ScreenKitAppGroup") as? String, + let root = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group), + let keychainGroup = bundle.object(forInfoDictionaryKey: "ScreenKitKeychainGroup") as? String, + !keychainGroup.isEmpty, !keychainGroup.contains("$(") else { throw ScreenKitError.signing } + self.root = root; self.keychainGroup = keychainGroup + } + private func url(_ name: String) -> URL { root.appendingPathComponent(name + ".json") } + private func read(_ name: String) -> T? { + guard let data = try? Data(contentsOf: url(name)), data.count < 32768 else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + private func write(_ value: T, name: String) throws { + let data = try JSONEncoder().encode(value) + try data.write(to: url(name), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + var file = url(name); var values = URLResourceValues(); values.isExcludedFromBackup = true + try file.setResourceValues(values) + } + func status() -> BroadcastStatus? { read("status") } + func control() -> BroadcastControl? { read("control") } + func update(_ status: BroadcastStatus) throws { try write(status, name: "status") } + func command(_ control: BroadcastControl) throws { try write(control, name: "control") } + func prepared() -> BroadcastConfiguration? { read("prepared") } + func prepare(_ config: BroadcastConfiguration, key: String) throws { + try config.validate() + _ = try StreamDestination(server: config.server, key: key) + guard status()?.isActive() != true else { throw ScreenKitError.capture } + clearPrepared() + var query = keyQuery(config.id) + query[kSecValueData as String] = Data(key.utf8) + query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + guard SecItemAdd(query as CFDictionary, nil) == errSecSuccess else { throw ScreenKitError.credential } + do { try write(config, name: "prepared"); try command(.init(id: config.id, muted: false)) } + catch { SecItemDelete(keyQuery(config.id) as CFDictionary); throw error } + } + func consume() throws -> (BroadcastConfiguration, StreamDestination) { + guard let config: BroadcastConfiguration = read("prepared") else { throw ScreenKitError.expired } + defer { clearPrepared() } + try config.validate() + var query = keyQuery(config.id) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, let key = String(data: data, encoding: .utf8) else { throw ScreenKitError.credential } + return (config, try StreamDestination(server: config.server, key: key)) + } + func clearPrepared() { + if let config: BroadcastConfiguration = read("prepared") { SecItemDelete(keyQuery(config.id) as CFDictionary) } + try? FileManager.default.removeItem(at: url("prepared")) + } + func removeExpiredPreparation() { + if let config = prepared(), (try? config.validate()) == nil { clearPrepared() } + } + private func keyQuery(_ id: UUID) -> [String: Any] { + [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "ScreenKit.Broadcast.OneUse", + kSecAttrAccount as String: id.uuidString, kSecAttrAccessGroup as String: keychainGroup] + } +} diff --git a/ios/TESTING.md b/ios/TESTING.md new file mode 100644 index 0000000..ab92a11 --- /dev/null +++ b/ios/TESTING.md @@ -0,0 +1,43 @@ +# iOS acceptance checklist + +CI compilation is not proof of a working signed device broadcast. Use a physical iPhone +and iPad running iOS 17+ and a private test destination. Never commit real keys. + +## Signed build / privacy +- Both targets install and the ScreenKit Live provider appears in Apple's system picker. +- Mismatched/missing App Group or Keychain Sharing profiles show a clear setup error. +- Verify temporary Keychain key deletion on consume/cancel, expired setup rejection, + and cleanup when reopening the app. No key in shared JSON, logs, backups or analytics. +- iOS consent always remains user-controlled. Decline mic/Photos permissions; other tools + remain usable. Test screen-sharing privacy labels, Focus mode, and protected screens. + +## Local recording & editing +- Start/stop an in-app recording with mic off and on; play saved MP4 and verify sound/sync. +- App interruption, cancellation, screen lock and low storage produce truthful status. +- Import from Photos including an iCloud movie, and from Files/security-scoped storage. +- Trim rotated and landscape videos; inspect output orientation and sound. PNG frame + time is accurate. GIF loops and respects 15s/480px limits. Cancel processing, dismiss + editor, save to Photos, share to Files, delete capture, relaunch and confirm library. +- Unsupported/corrupt/over-1GB files and invalid/NaN/reversed ranges fail safely. + +## Broadcast +- RTMPS to private YouTube/Facebook broadcast; RTMP to a private compatible test receiver. +- Device, mic, mixed and silent audio; mic disabled in Apple's picker; no app sound; + muted device and source apps that prohibit playback capture. Check audio/video sync + at startup and after 10 minutes. Mute/unmute; no fresh audio after queued data drains. +- Portrait/landscape rotation, 480p/720p, 24/30fps, low/high bitrate. Verify actual receiver + video (including SPS changes), not just ScreenKit's connected state. +- Track extension memory on high-resolution iPhones/iPads. The OS may terminate extensions + around its memory budget. Validate that queues stay bounded over a 30-minute stream. +- Stop via app, red system indicator and Control Center while connecting and while live. + Cancel consent, lock screen, background/terminate the host app, pause/resume sharing. +- Invalid key, unreachable server, invalid TLS certificate/hostname, network change/drop, + slow uplink and server rejection must end cleanly. No silent automatic reconnection. +- Host status uses heartbeat freshness, never assumes public-live or viewer count. Status + must not remain active after extension termination. New setup must work after failure. + +## Before public distribution +- Developer-owned signing IDs, App Groups and privacy/support URLs, App Store privacy + answers, screenshots and age rating, export compliance and dependency review. +- Check current platform policies; no accessibility or protected-capture bypass claims. +- Record physical-device outcomes here before describing platform compatibility as verified. diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..39b1788 --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,71 @@ +name: ScreenKit +options: + minimumXcodeGenVersion: 2.42.0 + deploymentTarget: + iOS: "17.0" +settings: + base: + SWIFT_VERSION: "5.0" + IPHONEOS_DEPLOYMENT_TARGET: "17.0" + TARGETED_DEVICE_FAMILY: "1,2" + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + CODE_SIGN_STYLE: Automatic + SCREENKIT_APP_GROUP: group.com.screenkit.ios + SCREENKIT_KEYCHAIN_GROUP: "$(AppIdentifierPrefix)com.screenkit.ios.shared" + SCREENKIT_BROADCAST_ID: com.screenkit.ios.broadcast +packages: + HaishinKit: + url: https://github.com/HaishinKit/HaishinKit.swift.git + exactVersion: 2.1.0 + Logboard: + url: https://github.com/shogo4405/Logboard.git + exactVersion: 2.5.0 +targets: + ScreenKit: + type: application + platform: iOS + sources: + - ScreenKit + - Shared + - ScreenKitCore/Sources/ScreenKitCore + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.screenkit.ios + INFOPLIST_FILE: ScreenKit/Info.plist + CODE_SIGN_ENTITLEMENTS: ScreenKit/ScreenKit.entitlements + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + dependencies: + - target: ScreenKitBroadcast + embed: true + ScreenKitBroadcast: + type: app-extension + platform: iOS + sources: + - BroadcastUpload + - Shared + - ScreenKitCore/Sources/ScreenKitCore + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: "$(SCREENKIT_BROADCAST_ID)" + INFOPLIST_FILE: BroadcastUpload/Info.plist + CODE_SIGN_ENTITLEMENTS: BroadcastUpload/BroadcastUpload.entitlements + APPLICATION_EXTENSION_API_ONLY: YES + SKIP_INSTALL: YES + dependencies: + - package: HaishinKit + product: HaishinKit + - package: HaishinKit + product: RTMPHaishinKit + - package: Logboard + product: Logboard +schemes: + ScreenKit: + build: + targets: + ScreenKit: all + ScreenKitBroadcast: all + run: + config: Debug + archive: + config: Release diff --git a/ios/scripts/generate.sh b/ios/scripts/generate.sh new file mode 100755 index 0000000..afc13b0 --- /dev/null +++ b/ios/scripts/generate.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +command -v xcodegen >/dev/null || { echo 'Install XcodeGen with: brew install xcodegen'; exit 1; } +xcodegen generate --spec project.yml From b1c1968c369dd3c59b369877a2ebf5f49e148e86 Mon Sep 17 00:00:00 2001 From: sharthak18 <157966738+sharthak18@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:59:54 +0000 Subject: [PATCH 06/10] Fix floating long capture and live zoom; add custom and ad-aware scroll controls Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/android-apk.yml | 4 +- README.md | 41 ++++- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 5 + .../screenrecorder/CaptureContract.kt | 1 + .../screenrecorder/CaptureIntents.kt | 25 ++- .../screenrecorder/CaptureRequestActivity.kt | 21 ++- .../screenkit/screenrecorder/MainActivity.kt | 5 + .../automation/PlaybackInspector.kt | 55 ++++++ .../automation/SmartScrollPolicy.kt | 116 +++++++++++++ .../screenrecorder/data/SettingsStore.kt | 9 +- .../services/FloatingOverlayService.kt | 8 +- .../services/MagnifierCaptureService.kt | 150 ++++++++++++++++ .../services/ScreenCaptureService.kt | 109 +++++++----- .../services/ScreenKitAutoService.kt | 8 +- .../ui/AutoClickScrollOverlay.kt | 67 ++++++- .../screenrecorder/ui/CaptureViewModel.kt | 1 + .../screenrecorder/ui/CreatorToolsOverlay.kt | 163 +++++++++++------- .../screenkit/screenrecorder/ui/MainScreen.kt | 15 ++ .../ui/ScrollPreferencesDialog.kt | 58 +++++++ app/src/main/res/values/strings.xml | 2 +- .../res/xml/auto_accessibility_service.xml | 4 +- .../automation/SmartScrollPolicyTest.kt | 84 +++++++++ docs/android-2.1.1-testing.md | 28 +++ 24 files changed, 852 insertions(+), 131 deletions(-) create mode 100644 app/src/main/java/com/screenkit/screenrecorder/automation/PlaybackInspector.kt create mode 100644 app/src/main/java/com/screenkit/screenrecorder/automation/SmartScrollPolicy.kt create mode 100644 app/src/main/java/com/screenkit/screenrecorder/services/MagnifierCaptureService.kt create mode 100644 app/src/main/java/com/screenkit/screenrecorder/ui/ScrollPreferencesDialog.kt create mode 100644 app/src/test/java/com/screenkit/screenrecorder/automation/SmartScrollPolicyTest.kt create mode 100644 docs/android-2.1.1-testing.md diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index b335efb..80103e0 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -99,8 +99,8 @@ jobs: - name: Publish APK to GitHub Release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.1.0' }} - name: ScreenKit ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.1.0' }} + tag_name: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.1.1' }} + name: ScreenKit ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.1.1' }} target_commitish: ${{ github.sha }} make_latest: true generate_release_notes: true diff --git a/README.md b/README.md index 27c9bf6..229f8c4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ScreenKit (v2.1.0) +# ScreenKit (v2.1.1) A native, high-performance Android capture, creator studio & screen automation suite built with Modern Android Architecture (Jetpack Compose, CameraX, Foreground Services, Accessibility Gesture Engine, MediaProjection, OpenGL ES, MediaMuxer/MediaExtractor, and MediaCodec). ScreenKit v2.1.0 adds **Go Live screen streaming to YouTube, Facebook, and custom RTMP/RTMPS destinations** alongside screen recording with **Pause & Resume**, custom area cropping, **Scrolling Long Screenshots ("Stitch Full Page")**, built-in **MP4 Video Trimmer, Animated GIF Maker & PNG Frame Extractor**, sequential multi-target **Auto Clicking (`1 → 2 → 3 → 4…`)**, directional **Auto Scrolling (`↑ / ↓ / ← / →`)**, **Touch Macro Recorder & Replay**, **Saved Auto-Clicker Profiles (Slots 1–3)**, **Anti-Detection Human Jitter**, **Smart Color / Pixel Trigger (`#HEX`)**, **Floating Teleprompter**, **On-Screen Magnifier Loupe & Color Eyedropper**, **Creator Watermark Badge**, **Shake / Volume-Down Emergency Stop**, **Live Usage Analytics & One-Tap In-App Updater**, live interactive brush annotation, on-screen touch visualization, a draggable floating camera lens, amplified sound recording (Mic / Device Audio / Mixed), and a built-in Black-Screen Bypass Web Capture Browser. @@ -14,9 +14,9 @@ Android-only automation/overlay and protected-capture features are not ported. ## Download and Install -[⬇️ **Download the latest ScreenKit v2.1.0 APK**](https://github.com/sharthak18/ScreenKit/releases/latest/download/ScreenKit.apk) +[⬇️ **Download the latest ScreenKit v2.1.1 APK**](https://github.com/sharthak18/ScreenKit/releases/latest/download/ScreenKit.apk) -- **Current Version**: `v2.1.0` (`versionCode = 8`) +- **Current Version**: `v2.1.1` (`versionCode = 9`) - Compatible with **Android 7.0 (API 24) to Android 15 (API 35)**. - Device Audio & Mixed (`Mic + Device`) playback capture supported on **Android 10+**. - Full screen scope and single-app exclusion selection supported on **Android 14+**. @@ -24,6 +24,41 @@ Android-only automation/overlay and protected-capture features are not ported. --- +## Fixed in v2.1.1 + +- **Floating Long Screenshot:** open the bubble and tap **Long SS** on its third row. + Enable the ScreenKit Accessibility service first. Capture hides overlays, stops automatic + scrolling, and samples up to three settled screens (or stops when the page does not change). + Long-image stitching is approximate on sticky/dynamic layouts, not guaranteed seamless. +- **Real Zoom feed:** Zoom now asks for Android whole-screen sharing and reads fresh screen + pixels up to 5 times/second. Drag the panel; the sample target is 20dp below it (outside + the magnifier itself). The 3× loupe and color sample use this live feed, not a saved image + or generated colors. The feed is scaled to a 1600px long edge for memory use; sampled + colors are from that scaled feed. Close Zoom or its notification to release capture. + No pixels are saved or sent anywhere. Rotate/reopen Zoom to realign coordinates. + Zoom is exclusive with recording, broadcasting, and screenshots. Protected screens may + remain black. Color-trigger clicks now wait rather than fire when live pixels are unavailable. +- **Custom scroll delay:** **Custom seconds / Smart playback** is available in the app and + floating Auto Scroll settings. Enter **0.2–3600 seconds** (including decimals), e.g. + 30, 90, 300 or 900. The first item receives the same viewing delay. Session duration is + separate: choose **Infinite** if you want a delay longer than the total run time. +- **Smart playback (experimental, opt-in):** while Auto Scroll runs, reads visible + accessibility controls in YouTube, Instagram, Facebook/Facebook Lite. Explicit + sponsored-feed labels can advance the feed; visible **Skip ad** buttons can be used; + recognised in-video ad/countdown labels hold scrolling until the ad finishes. Exposed + playback progress can advance on video end/loop instead of repeating a short video. + The timer restarts after an ad when playback information is unavailable. **Hold/Resume** + and **Next** are always available on the running scroll bar. +- **Limits:** no bypass of unskippable ads; no guarantee of detecting every app version, + language, ad format, or hidden player timeline. When progress is unavailable, the banner + explicitly says **timed fallback**. Unsupported apps do not auto-swipe in Smart mode; + use Timed mode there. Manual Hold/Next handles cases the app cannot observe. +- **Privacy:** the Accessibility service now declares window-content retrieval for Smart + mode. Inspection is opt-in, limited to visible controls while automation runs, and bounded + to a small node/time budget. No text is logged, persisted or sent. After updating, re-enable + ScreenKit in Android Accessibility if the new capability is not active. +- Fixed a separate service-action dispatch bug that rejected the Go Live start action. + ## What's New in v2.1.0 **Go Live** adds RTMP/RTMPS screen streaming with quality settings, microphone/device/mixed audio, diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2852b15..8754b7a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.screenkit.screenrecorder" minSdk = 24 targetSdk = 34 - versionCode = 8 - versionName = "2.1.0" + versionCode = 9 + versionName = "2.1.1" } compileOptions { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cd14d33..f37f28f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -61,6 +61,11 @@ android:taskAffinity="" android:theme="@style/Theme.ScreenKit.Transparent" /> + + = Build.VERSION_CODES.UPSIDE_DOWN_CAKE && - SettingsStore.projectionScope(context) == ProjectionScope.ENTIRE_SCREEN + (wholeDisplay || SettingsStore.projectionScope(context) == ProjectionScope.ENTIRE_SCREEN) ) { manager.createScreenCaptureIntent(MediaProjectionConfig.createConfigForDefaultDisplay()) } else { @@ -43,6 +43,21 @@ internal object CaptureIntents { data: Intent, cropRect: android.graphics.RectF? = null, ): Boolean { + if (mode == CaptureContract.MODE_MAGNIFIER) { + return startForeground(context, Intent(context, com.screenkit.screenrecorder.services.MagnifierCaptureService::class.java) + .putExtra(CaptureContract.EXTRA_RESULT_CODE, resultCode).putExtra(CaptureContract.EXTRA_RESULT_DATA, data)) + } + if (com.screenkit.screenrecorder.services.MagnifierCaptureService.active) { + toast(context, "Close Zoom before starting another screen capture.") + return false + } + if (mode == CaptureContract.MODE_LONG_SCREENSHOT) { + if (com.screenkit.screenrecorder.services.ScreenKitAutoService.instance == null) { + toast(context, "Long Screenshot needs ScreenKit Auto Click & Scroll enabled in Accessibility.") + return false + } + com.screenkit.screenrecorder.ui.AutoClickScrollOverlay.stopExecution() + } val audio = SettingsStore.audioMode(context) val intent = Intent(context, ScreenCaptureService::class.java) .setAction( @@ -71,6 +86,10 @@ internal object CaptureIntents { } fun startCameraRecording(context: Context): Boolean { + if (com.screenkit.screenrecorder.services.MagnifierCaptureService.active) { + toast(context, "Close Zoom before starting a camera recording.") + return false + } val audio = SettingsStore.audioMode(context) val audioCapable = audio.usesMicrophone && hasPermission(context, Manifest.permission.RECORD_AUDIO) val intent = Intent(context, CameraCaptureService::class.java) @@ -163,7 +182,7 @@ internal object CaptureIntents { ) { required += Manifest.permission.POST_NOTIFICATIONS } - if (mode != CaptureContract.MODE_LIVE_STREAM && Build.VERSION.SDK_INT <= Build.VERSION_CODES.P && !canWriteExternalStorage(context)) { + if (mode != CaptureContract.MODE_MAGNIFIER && mode != CaptureContract.MODE_LIVE_STREAM && Build.VERSION.SDK_INT <= Build.VERSION_CODES.P && !canWriteExternalStorage(context)) { required += Manifest.permission.WRITE_EXTERNAL_STORAGE } when (mode) { diff --git a/app/src/main/java/com/screenkit/screenrecorder/CaptureRequestActivity.kt b/app/src/main/java/com/screenkit/screenrecorder/CaptureRequestActivity.kt index a94c63b..d29ffd1 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/CaptureRequestActivity.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/CaptureRequestActivity.kt @@ -129,8 +129,9 @@ class CaptureRequestActivity : ComponentActivity() { finishSafely() } CaptureContract.COMMAND_TOGGLE_MAGNIFIER -> { - CreatorToolsOverlay.toggleMagnifier(this) - finishSafely() + if (CreatorToolsOverlay.isMagnifierShowing) { + CreatorToolsOverlay.hideMagnifier(); finishSafely() + } else requestScreenCapture(CaptureContract.MODE_MAGNIFIER) } CaptureContract.COMMAND_TOGGLE_WATERMARK -> { CreatorToolsOverlay.toggleWatermark(this) @@ -254,7 +255,17 @@ class CaptureRequestActivity : ComponentActivity() { } private fun continueScreenCapture(mode: String) { - if ((mode == CaptureContract.MODE_SCREEN_RECORDING || mode == CaptureContract.MODE_PARTIAL_SCREEN_RECORDING) && + if (ScreenCaptureService.isActive || com.screenkit.screenrecorder.services.MagnifierCaptureService.active) { + FloatingOverlayService.setHiddenForScreenshot(this, false) + CaptureIntents.toast(this, "Stop the current screen capture or close Zoom first.") + finishSafely(); return + } + if (mode == CaptureContract.MODE_LONG_SCREENSHOT && com.screenkit.screenrecorder.services.ScreenKitAutoService.instance == null) { + FloatingOverlayService.setHiddenForScreenshot(this, false) + CaptureIntents.toast(this, "Enable ScreenKit Auto Click & Scroll in Accessibility for Long Screenshot.") + finishSafely(); return + } + if ((mode == CaptureContract.MODE_MAGNIFIER || mode == CaptureContract.MODE_SCREEN_RECORDING || mode == CaptureContract.MODE_PARTIAL_SCREEN_RECORDING) && CameraCaptureService.isRecording ) { FloatingOverlayService.setHiddenForScreenshot(this, false) @@ -262,13 +273,13 @@ class CaptureRequestActivity : ComponentActivity() { finishSafely() return } - if (!CaptureIntents.canWriteExternalStorage(this)) { + if (mode != CaptureContract.MODE_MAGNIFIER && !CaptureIntents.canWriteExternalStorage(this)) { FloatingOverlayService.setHiddenForScreenshot(this, false) CaptureIntents.toast(this, "Storage permission is needed to save captures on this Android version.") finishSafely() return } - val consent = CaptureIntents.projectionConsentIntent(this) + val consent = CaptureIntents.projectionConsentIntent(this, wholeDisplay = mode == CaptureContract.MODE_MAGNIFIER) if (consent == null) { FloatingOverlayService.setHiddenForScreenshot(this, false) CaptureIntents.toast(this, "Screen capture is not available on this device.") diff --git a/app/src/main/java/com/screenkit/screenrecorder/MainActivity.kt b/app/src/main/java/com/screenkit/screenrecorder/MainActivity.kt index 993a8bc..bdba353 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/MainActivity.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/MainActivity.kt @@ -560,6 +560,7 @@ class MainActivity : ComponentActivity() { autoScrollIntervalMs = SettingsStore.autoScrollIntervalMs(this), autoScrollDurationSec = SettingsStore.autoScrollDurationSec(this), autoScrollDistancePct = SettingsStore.autoScrollDistancePct(this), + autoScrollSmartEnabled = SettingsStore.autoScrollSmartEnabled(this), autoRandomJitter = SettingsStore.autoRandomJitter(this), autoColorTriggerEnabled = SettingsStore.autoColorTriggerEnabled(this), autoColorTriggerHex = SettingsStore.autoColorTriggerHex(this), @@ -665,6 +666,10 @@ class MainActivity : ComponentActivity() { } private fun requestScreenCapture(mode: String) { + if (com.screenkit.screenrecorder.services.MagnifierCaptureService.active) { + showMessage("Close Zoom before starting another screen capture.") + return + } val state = viewModel.state.value if (state.screenRecording) { if (mode == CaptureContract.MODE_SCREENSHOT || mode == CaptureContract.MODE_PARTIAL_SCREENSHOT) { diff --git a/app/src/main/java/com/screenkit/screenrecorder/automation/PlaybackInspector.kt b/app/src/main/java/com/screenkit/screenrecorder/automation/PlaybackInspector.kt new file mode 100644 index 0000000..f00d35b --- /dev/null +++ b/app/src/main/java/com/screenkit/screenrecorder/automation/PlaybackInspector.kt @@ -0,0 +1,55 @@ +package com.screenkit.screenrecorder.automation + +import android.view.accessibility.AccessibilityNodeInfo +import android.os.SystemClock + +/** On-device, bounded inspection, invoked only while user-enabled smart scrolling is running. */ +internal object PlaybackInspector { + private val packages = setOf("com.google.android.youtube", "com.instagram.android", "com.facebook.katana", "com.facebook.lite") + @Suppress("DEPRECATION") + fun inspect(root: AccessibilityNodeInfo?, clickSkip: Boolean = false): PlaybackObservation { + root ?: return PlaybackObservation() + val app = root.packageName?.toString().orEmpty() + if (app !in packages) { root.recycle(); return PlaybackObservation(packageName = app) } + val stack = java.util.ArrayDeque() + stack.add(root) + var nodes = 0 + var ad = false; var sponsored = false; var skip = false; var clicked = false + var progress: Double? = null; var paused = false + val deadline = SystemClock.uptimeMillis() + 35L + try { + while (stack.isNotEmpty() && nodes++ < 250 && SystemClock.uptimeMillis() < deadline) { + val node = stack.removeFirst() + try { + if (!node.isVisibleToUser || node.packageName?.toString() != app) continue + val labels = listOfNotNull(node.text?.toString(), node.contentDescription?.toString()).map { it.take(160) } + val id = node.viewIdResourceName.orEmpty().lowercase() + // Comment/caption text is intentionally not used as player/ad evidence. + val playerControl = node.className?.toString()?.contains("SeekBar") == true || + id.contains("progress") || id.contains("player") || id.contains("time_bar") || id.contains("timestamp") + if (playerControl) { + val range = node.rangeInfo + if (range != null && range.max > range.min) { + progress = ((range.current - range.min) / (range.max - range.min)).toDouble() + } + labels.firstNotNullOfOrNull { PlaybackLabels.progress(it) }?.let { progress = it } + if (node.isClickable && labels.any { it.equals("play", true) || it.equals("play video", true) }) paused = true + } + // Only compact UI labels; never inspect long post/comment bodies for keywords. + if (!id.contains("comment") && !id.contains("caption") && !id.contains("description")) { + labels.filter { it.length <= 70 }.forEach { + ad = ad || PlaybackLabels.adBreak(it) + sponsored = sponsored || PlaybackLabels.sponsored(it) + if (PlaybackLabels.skip(it) && node.isClickable && node.isEnabled) { + skip = true + if (clickSkip && !clicked) clicked = node.performAction(AccessibilityNodeInfo.ACTION_CLICK) + } + } + } + for (i in 0 until node.childCount.coerceAtMost(40)) node.getChild(i)?.let(stack::addLast) + } finally { node.recycle() } + } + } finally { while (stack.isNotEmpty()) stack.removeFirst().recycle() } + return PlaybackObservation(app, true, ad, sponsored, skip, progress, paused) + } +} diff --git a/app/src/main/java/com/screenkit/screenrecorder/automation/SmartScrollPolicy.kt b/app/src/main/java/com/screenkit/screenrecorder/automation/SmartScrollPolicy.kt new file mode 100644 index 0000000..f61a803 --- /dev/null +++ b/app/src/main/java/com/screenkit/screenrecorder/automation/SmartScrollPolicy.kt @@ -0,0 +1,116 @@ +package com.screenkit.screenrecorder.automation + +/** No Android dependency: conservative decisions are regression-tested with synthetic observations. */ +internal data class PlaybackObservation( + val packageName: String = "", + val supported: Boolean = false, + val adBreak: Boolean = false, + val sponsoredFeed: Boolean = false, + val skipAvailable: Boolean = false, + val progress: Double? = null, + val paused: Boolean = false, +) +internal enum class ScrollDecision { WAIT, NEXT, SKIP_AD } +internal data class ScrollAdvice(val decision: ScrollDecision, val message: String) + +internal class SmartScrollPolicy { + private var lastTick = 0L + private var watchMs = 0L + private var previousProgress: Double? = null + private var progressSeenAt = 0L + private var app = "" + private var skipAttemptAt = -10_000L + private var resumedAfterAdAt = -10_000L + private var inAd = false + + fun reset(now: Long) { + lastTick = now; watchMs = 0; previousProgress = null; progressSeenAt = 0 + skipAttemptAt = -10_000L; resumedAfterAdAt = -10_000L; inAd = false; app = "" + } + fun decide(now: Long, intervalMs: Long, smart: Boolean, hold: Boolean, observation: PlaybackObservation): ScrollAdvice { + val elapsed = (now - lastTick).coerceIn(0, 1000) + lastTick = now + if (hold) return ScrollAdvice(ScrollDecision.WAIT, "HOLD · tap Resume or Next") + if (!smart) { + watchMs += elapsed + return timed(intervalMs) + } + if (!observation.supported) return ScrollAdvice(ScrollDecision.WAIT, "Smart: open YouTube, Instagram or Facebook") + if (app != observation.packageName) { + reset(now); app = observation.packageName + } + if (observation.adBreak || observation.skipAvailable) { + inAd = true + // A skip command is only a click on a currently visible, labelled Skip ad button. + if (observation.skipAvailable && now - skipAttemptAt >= 3000) { + skipAttemptAt = now + return ScrollAdvice(ScrollDecision.SKIP_AD, "Using the visible Skip ad button") + } + return ScrollAdvice(ScrollDecision.WAIT, "Ad break · holding this video until it resumes") + } + if (inAd) { + inAd = false; resumedAfterAdAt = now; watchMs = 0 + previousProgress = null; progressSeenAt = 0 + } + // Don't interpret an ad-to-content progress jump or a briefly lingering sponsor label as an end. + if (now - resumedAfterAdAt < 2000) return ScrollAdvice(ScrollDecision.WAIT, "Content resuming after ad…") + if (observation.sponsoredFeed && watchMs >= 1200) return ScrollAdvice(ScrollDecision.NEXT, "Skipping labelled sponsored feed item") + if (observation.paused) return ScrollAdvice(ScrollDecision.WAIT, "Video paused · waiting for playback") + watchMs += elapsed + val progress = observation.progress?.takeIf { it.isFinite() && it in 0.0..1.0 } + if (progress != null) { + val old = previousProgress + previousProgress = progress; progressSeenAt = now + if (watchMs >= 1500 && ((progress >= 0.985 && old != null && progress > old) || + (old != null && old >= 0.65 && progress <= 0.15))) { + return ScrollAdvice(ScrollDecision.NEXT, "Video ended / loop detected") + } + return ScrollAdvice(ScrollDecision.WAIT, "Watching video · ${(progress * 100).toInt()}%") + } + if (progressSeenAt > 0 && now - progressSeenAt < 5000) { + return ScrollAdvice(ScrollDecision.WAIT, "Waiting for playback progress…") + } + return timed(intervalMs, "No playback info · timed fallback") + } + private fun timed(interval: Long, prefix: String = "Timed scroll"): ScrollAdvice { + if (watchMs >= interval) return ScrollAdvice(ScrollDecision.NEXT, "Next item") + val seconds = (interval - watchMs + 999) / 1000 + return ScrollAdvice(ScrollDecision.WAIT, "$prefix · ${seconds}s") + } +} + +internal object ScrollDelay { + const val MIN_MS = 200L + const val MAX_MS = 3_600_000L + fun parseSeconds(text: String): Long? { + val seconds = text.trim().toDoubleOrNull() ?: return null + if (!seconds.isFinite() || seconds < 0.2 || seconds > 3600) return null + return (seconds * 1000).toLong().coerceIn(MIN_MS, MAX_MS) + } +} + +/** Parse only explicit playback/ad labels, not arbitrary timer-looking comment text. */ +internal object PlaybackLabels { + private val timePair = Regex("(?:^|\\s)(\\d{1,2}:\\d{2}(?::\\d{2})?)\\s*(?:/|of)\\s*(\\d{1,2}:\\d{2}(?::\\d{2})?)(?:$|\\s)", RegexOption.IGNORE_CASE) + fun progress(label: String): Double? { + val match = timePair.find(label.trim()) ?: return null + fun seconds(s: String): Long? { + val parts = s.split(':').map { it.toLongOrNull() ?: return null } + if (parts.drop(1).any { it !in 0..59 }) return null + return parts.fold(0L) { a, v -> a * 60 + v } + } + val elapsed = seconds(match.groupValues[1]) ?: return null + val total = seconds(match.groupValues[2]) ?: return null + return if (total > 0 && elapsed in 0..total) elapsed.toDouble() / total else null + } + fun skip(label: String): Boolean = label.trim().lowercase() in setOf("skip ad", "skip ads", "skip advertisement") + fun adBreak(label: String): Boolean { + val value = label.trim().lowercase() + return Regex("^ad\\s+\\d+\\s+of\\s+\\d+.*$").matches(value) || + Regex("^ad\\s*[·•:–-]?\\s*\\d{1,2}:\\d{2}.*$").matches(value) || + value.startsWith("ad ends in") || value.startsWith("your video will resume") || + value.startsWith("video will resume after") || value.startsWith("video will play after") || + value == "advertisement" || value == "ad" + } + fun sponsored(label: String): Boolean = label.trim().lowercase() in setOf("sponsored", "sponsored post", "স্পন্সরড") +} diff --git a/app/src/main/java/com/screenkit/screenrecorder/data/SettingsStore.kt b/app/src/main/java/com/screenkit/screenrecorder/data/SettingsStore.kt index 9eae313..f26a1e1 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/data/SettingsStore.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/data/SettingsStore.kt @@ -334,10 +334,15 @@ internal object SettingsStore { /** Delay / pause between consecutive scroll gestures in ms. */ fun autoScrollIntervalMs(context: Context): Long = - prefs(context).getLong(CaptureContract.PREF_AUTO_SCROLL_INTERVAL_MS, 1500L).coerceIn(200L, 60_000L) + prefs(context).getLong(CaptureContract.PREF_AUTO_SCROLL_INTERVAL_MS, 1500L).coerceIn(200L, 3_600_000L) fun setAutoScrollIntervalMs(context: Context, intervalMs: Long) { - prefs(context).edit().putLong(CaptureContract.PREF_AUTO_SCROLL_INTERVAL_MS, intervalMs.coerceIn(200L, 60_000L)).apply() + prefs(context).edit().putLong(CaptureContract.PREF_AUTO_SCROLL_INTERVAL_MS, intervalMs.coerceIn(200L, 3_600_000L)).apply() + } + + fun autoScrollSmartEnabled(context: Context): Boolean = prefs(context).getBoolean("auto_scroll_smart_v1", false) + fun setAutoScrollSmartEnabled(context: Context, enabled: Boolean) { + prefs(context).edit().putBoolean("auto_scroll_smart_v1", enabled).apply() } /** How long the auto-scrolling will run in seconds (0 = Infinite until stopped). */ diff --git a/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt b/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt index cb0f5c8..ba59531 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt @@ -368,7 +368,7 @@ class FloatingOverlayService : Service() { menu, LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, - dp(CELL_HEIGHT_DP * 2 + if (ScreenCaptureService.isLive) 24 else 0), + dp(CELL_HEIGHT_DP * 3 + if (ScreenCaptureService.isLive) 24 else 0), ), ) root.addView( @@ -384,7 +384,7 @@ class FloatingOverlayService : Service() { menu, LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, - dp(CELL_HEIGHT_DP * 2 + if (ScreenCaptureService.isLive) 24 else 0), + dp(CELL_HEIGHT_DP * 3 + if (ScreenCaptureService.isLive) 24 else 0), ).apply { leftMargin = dp(4) }, ) } @@ -552,6 +552,10 @@ class FloatingOverlayService : Service() { MenuAction("🌐", "Bypass", CaptureContract.COMMAND_OPEN_WEB_BYPASS, false), MenuAction("⊞", "Home", CaptureContract.COMMAND_OPEN_APP, false), ), + listOf( + MenuAction("⇣", "Long SS", CaptureContract.COMMAND_LONG_SCREENSHOT, false), + MenuAction("©", "Badge", CaptureContract.COMMAND_TOGGLE_WATERMARK, CreatorToolsOverlay.isWatermarkShowing), + ), ) rows.forEach { actions -> val row = LinearLayout(this).apply { diff --git a/app/src/main/java/com/screenkit/screenrecorder/services/MagnifierCaptureService.kt b/app/src/main/java/com/screenkit/screenrecorder/services/MagnifierCaptureService.kt new file mode 100644 index 0000000..e788131 --- /dev/null +++ b/app/src/main/java/com/screenkit/screenrecorder/services/MagnifierCaptureService.kt @@ -0,0 +1,150 @@ +package com.screenkit.screenrecorder.services + +import android.app.Activity +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.graphics.Bitmap +import android.graphics.PixelFormat +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.media.ImageReader +import android.media.projection.MediaProjection +import android.media.projection.MediaProjectionManager +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import android.os.IBinder +import android.os.SystemClock +import android.util.DisplayMetrics +import android.view.WindowManager +import android.widget.Toast +import androidx.core.app.NotificationCompat +import com.screenkit.screenrecorder.CaptureContract +import com.screenkit.screenrecorder.R +import com.screenkit.screenrecorder.ui.CreatorToolsOverlay +import java.util.concurrent.atomic.AtomicBoolean + +/** User-consented live feed for the loupe. No audio, file saving, network, or screenshot history. */ +class MagnifierCaptureService : Service() { + private val main = Handler(android.os.Looper.getMainLooper()) + private var worker: HandlerThread? = null + private var reader: ImageReader? = null + private var projection: MediaProjection? = null + private var display: VirtualDisplay? = null + @Volatile private var closed = false + private var lastFrameAt = 0L + private val deliveryPending = AtomicBoolean(false) + private var screenWidth = 0 + private var screenHeight = 0 + private val callback = object : MediaProjection.Callback() { + override fun onStop() { stopSelf() } + override fun onCapturedContentResize(width: Int, height: Int) { + // A fresh consent/feed is safer than sampling incorrect coordinates after rotation/app-only sharing. + if (width != screenWidth || height != screenHeight) { + Toast.makeText(this@MagnifierCaptureService, "Screen size changed. Reopen Zoom and share the entire screen.", Toast.LENGTH_LONG).show() + stopSelf() + } + } + } + private val rotationCheck = object : Runnable { + override fun run() { + if (closed) return + val metrics = metrics() + if (metrics.widthPixels != screenWidth || metrics.heightPixels != screenHeight) { + Toast.makeText(this@MagnifierCaptureService, "Screen rotated. Reopen Zoom to realign the live feed.", Toast.LENGTH_SHORT).show() + stopSelf() + } else main.postDelayed(this, 1000L) + } + } + companion object { + private const val CHANNEL = "live_magnifier" + private const val NOTIFICATION = 5104 + const val STOP = "screenkit.magnifier.STOP" + @Volatile var active = false + private set + fun stop(context: Context) { context.stopService(Intent(context, MagnifierCaptureService::class.java)) } + } + @Suppress("DEPRECATION") + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null || intent.action == STOP) { stopSelf(); return START_NOT_STICKY } + if (active) return START_NOT_STICKY + try { + if (Build.VERSION.SDK_INT >= 26) getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel(CHANNEL, "Live screen magnifier", NotificationManager.IMPORTANCE_LOW)) + val stop = PendingIntent.getService(this, 5104, Intent(this, javaClass).setAction(STOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + val notification = NotificationCompat.Builder(this, CHANNEL).setSmallIcon(R.drawable.ic_stat_capture) + .setContentTitle("Live magnifier active").setContentText("Screen pixels are processed on-device only. Tap Stop to end sharing.") + .setOngoing(true).setVisibility(NotificationCompat.VISIBILITY_PRIVATE).addAction(0, "Stop Zoom", stop).build() + if (Build.VERSION.SDK_INT >= 29) startForeground(NOTIFICATION, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION) + else startForeground(NOTIFICATION, notification) + check(android.provider.Settings.canDrawOverlays(this) && !ScreenCaptureService.isActive && !CameraCaptureService.isRecording) { "Capture is busy" } + val data = if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(CaptureContract.EXTRA_RESULT_DATA, Intent::class.java) + else intent.getParcelableExtra(CaptureContract.EXTRA_RESULT_DATA) + check(intent.getIntExtra(CaptureContract.EXTRA_RESULT_CODE, 0) == Activity.RESULT_OK && data != null) + val m = metrics(); screenWidth = m.widthPixels; screenHeight = m.heightPixels + val scale = minOf(1.0, 1600.0 / maxOf(screenWidth, screenHeight)) + val width = (screenWidth * scale).toInt().coerceAtLeast(2) + val height = (screenHeight * scale).toInt().coerceAtLeast(2) + worker = HandlerThread("LiveMagnifierPixels").also { it.start() } + val imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2) + reader = imageReader + projection = getSystemService(MediaProjectionManager::class.java).getMediaProjection(Activity.RESULT_OK, data!!) + projection!!.registerCallback(callback, main) + imageReader.setOnImageAvailableListener({ source -> + val image = runCatching { source.acquireLatestImage() }.getOrNull() ?: return@setOnImageAvailableListener + image.use { + val now = SystemClock.elapsedRealtime() + if (closed || now - lastFrameAt < 200 || !deliveryPending.compareAndSet(false, true)) return@setOnImageAvailableListener + lastFrameAt = now + try { + val plane = it.planes[0] + val paddedWidth = plane.rowStride / plane.pixelStride + val padded = Bitmap.createBitmap(paddedWidth, it.height, Bitmap.Config.ARGB_8888) + val frame = try { + padded.copyPixelsFromBuffer(plane.buffer) + if (paddedWidth == it.width) padded else Bitmap.createBitmap(padded, 0, 0, it.width, it.height) + } catch (error: Exception) { padded.recycle(); throw error } + if (frame !== padded) padded.recycle() + main.post { + try { + if (closed) frame.recycle() + else CreatorToolsOverlay.updateLiveFrame(frame, screenWidth, screenHeight) + } finally { deliveryPending.set(false) } + } + } catch (_: Exception) { deliveryPending.set(false); main.post { stopSelf() } } + } + }, Handler(worker!!.looper)) + display = projection!!.createVirtualDisplay("ScreenKit live magnifier", width, height, m.densityDpi, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, imageReader.surface, null, null) + active = true + CreatorToolsOverlay.showMagnifier(this) + main.postDelayed(rotationCheck, 1000L) + } catch (_: Exception) { + Toast.makeText(this, "Zoom could not start. Stop other captures, allow overlays, and approve whole-screen sharing.", Toast.LENGTH_LONG).show() + stopSelf() + } + return START_NOT_STICKY + } + @Suppress("DEPRECATION") + private fun metrics(): DisplayMetrics = DisplayMetrics().also { + (getSystemService(WINDOW_SERVICE) as WindowManager).defaultDisplay.getRealMetrics(it) + } + override fun onDestroy() { + closed = true; active = false + main.removeCallbacks(rotationCheck) + reader?.setOnImageAvailableListener(null, null) + runCatching { display?.release() }; display = null + runCatching { reader?.close() }; reader = null + runCatching { projection?.unregisterCallback(callback); projection?.stop() }; projection = null + worker?.quitSafely(); worker = null + CreatorToolsOverlay.hideMagnifier(stopFeed = false) + stopForeground(STOP_FOREGROUND_REMOVE) + super.onDestroy() + } + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenCaptureService.kt b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenCaptureService.kt index 9ddb8d4..036d0c9 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenCaptureService.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenCaptureService.kt @@ -195,7 +195,7 @@ class ScreenCaptureService : Service() { } return START_NOT_STICKY } - ACTION_SCREENSHOT, ACTION_LONG_SCREENSHOT, ACTION_START_RECORDING -> Unit + ACTION_SCREENSHOT, ACTION_LONG_SCREENSHOT, ACTION_START_RECORDING, ACTION_START_LIVE -> Unit else -> { stopSelf(startId) return START_NOT_STICKY @@ -576,8 +576,9 @@ class ScreenCaptureService : Service() { private fun startScreenshotCapture() { val metrics = realDisplayMetrics() - val width = metrics.widthPixels - val height = metrics.heightPixels + val scale = if (isLongScreenshotMode) minOf(1.0, 1920.0 / maxOf(metrics.widthPixels, metrics.heightPixels)) else 1.0 + val width = (metrics.widthPixels * scale).toInt() + val height = (metrics.heightPixels * scale).toInt() require(width > 0 && height > 0) { "Could not read the device screen dimensions." } // Ensure all floating overlays are hidden before capturing any display frames @@ -611,7 +612,7 @@ class ScreenCaptureService : Service() { // Fallback if the screen is completely still after the settle delay mainHandler.postDelayed({ if (sessionActive && mode == Mode.SCREENSHOT && !finishing && !screenshotClaimed.get()) { - captureStashedScreenshot() + if (isLongScreenshotMode) captureNextLongFrame() else captureStashedScreenshot() } }, SCREENSHOT_SETTLE_MS + SCREENSHOT_FALLBACK_MS) mainHandler.postDelayed({ @@ -632,6 +633,10 @@ class ScreenCaptureService : Service() { try { image = reader.acquireLatestImage() ?: return if (!sessionActive || finishing || screenshotClaimed.get()) return + if (isLongScreenshotMode) { + stashPendingFrame(image) + return // Long capture uses scheduled settled frames, not a single-shot fallback. + } if (skippedInitialFrames++ == 0) return val now = SystemClock.elapsedRealtime() @@ -665,42 +670,11 @@ class ScreenCaptureService : Service() { return } - // Multi-frame vertical stitch for Scrolling Long Screenshot mode! - if (isLongScreenshotMode && ScreenKitAutoService.instance != null && longScreenshotFrames.size < 3) { - if (longScreenshotScrollInFlight || now < longScreenshotNextCaptureElapsed) { - cropped.recycle() - return - } - longScreenshotFrames.add(cropped) - if (longScreenshotFrames.size < 3) { - longScreenshotScrollInFlight = true - val w = cropped.width.toFloat() - val h = cropped.height.toFloat() - mainHandler.post { - val svc = ScreenKitAutoService.instance - if (svc != null && sessionActive && !finishing) { - svc.performSwipe(w * 0.5f, h * 0.72f, w * 0.5f, h * 0.28f, 380L) { - longScreenshotNextCaptureElapsed = SystemClock.elapsedRealtime() + 450L - longScreenshotScrollInFlight = false - } - } else { - longScreenshotScrollInFlight = false - } - } - return - } - } - if (!screenshotClaimed.compareAndSet(false, true)) { cropped.recycle() return } - - var finalBitmap = if (isLongScreenshotMode && longScreenshotFrames.size >= 2) { - stitchVerticalBitmaps(longScreenshotFrames) - } else { - cropped - } + var finalBitmap = cropped var finalBlank = isBlack if (finalBlank) { // Attempt hardware/shell screencap fallback if MediaProjection returned a black frame @@ -712,7 +686,6 @@ class ScreenCaptureService : Service() { } } - CreatorToolsOverlay.updateSampleBitmap(finalBitmap) bitmap = finalBitmap val saved = saveScreenshot(finalBitmap) mainHandler.post { @@ -723,7 +696,7 @@ class ScreenCaptureService : Service() { keepVideo = false, screenshot = saved, message = if (finalBlank) { - "Screenshot is blank due to app/site DRM. Tap 'Bypass' on the floating bubble to capture restricted websites." + "Screenshot is blank. The app may protect this screen; ScreenKit cannot bypass protected content." } else { "Screenshot saved to ${SettingsStore.describeSaveLocation(this@ScreenCaptureService)}." }, @@ -770,6 +743,55 @@ class ScreenCaptureService : Service() { } } + private fun captureNextLongFrame() { + if (!sessionActive || finishing || screenshotClaimed.get()) return + imageHandler?.post { + if (!sessionActive || finishing || screenshotClaimed.get()) return@post + val frame = synchronized(pendingLock) { pendingBitmap?.takeUnless { it.isRecycled }?.copy(Bitmap.Config.ARGB_8888, false) } + if (frame == null) { + mainHandler.postDelayed({ captureNextLongFrame() }, 350L) + return@post + } + try { + val done = synchronized(longScreenshotFrames) { + if (finishing) { frame.recycle(); return@post } + val duplicate = longScreenshotFrames.lastOrNull()?.sameAs(frame) == true + if (duplicate) frame.recycle() else longScreenshotFrames.add(frame) + duplicate || longScreenshotFrames.size >= 3 + } + if (done) { + if (!screenshotClaimed.compareAndSet(false, true)) return@post + val combined = synchronized(longScreenshotFrames) { + stitchVerticalBitmaps(longScreenshotFrames).also { longScreenshotFrames.clear() } + } + val saved = try { saveScreenshot(combined) } finally { if (!combined.isRecycled) combined.recycle() } + mainHandler.post { + if (!finishing) finishSession(false, saved, + if (saved != null) "Long Screenshot saved. Stitching may vary with sticky headers and moving content." + else "Long Screenshot could not be saved.", true) + } + } else { + mainHandler.post { + if (!sessionActive || finishing) return@post + val service = ScreenKitAutoService.instance + if (service == null) { + finishSession(false, null, "Long Screenshot stopped: accessibility was disconnected.", true) + } else { + val metrics = realDisplayMetrics() + service.performSwipe(metrics.widthPixels * 0.5f, metrics.heightPixels * 0.72f, + metrics.widthPixels * 0.5f, metrics.heightPixels * 0.28f, 600L) { success -> + if (success) mainHandler.postDelayed({ captureNextLongFrame() }, 700L) + else if (!finishing) finishSession(false, null, "Long Screenshot stopped: the scroll gesture was interrupted.", true) + } + } + } + } + } catch (_: Exception) { + mainHandler.post { if (!finishing) finishSession(false, null, "Long Screenshot could not finish. Try a static page.", true) } + } + } + } + private fun captureStashedScreenshot() { val stashed = synchronized(pendingLock) { val current = pendingBitmap @@ -816,7 +838,7 @@ class ScreenCaptureService : Service() { screenshot = saved, message = when { saved == null -> "The screenshot could not be saved. Check free storage and try again." - blank -> "Screenshot is blank due to app/site DRM. Tap 'Bypass' on the floating bubble to capture restricted websites." + blank -> "Screenshot is blank. The app may protect this screen; ScreenKit cannot bypass protected content." else -> "Screenshot saved to ${SettingsStore.describeSaveLocation(this)}." }, stopProjection = true, @@ -831,8 +853,8 @@ class ScreenCaptureService : Service() { if (frames.size == 1) return frames.first() val width = frames.first().width val singleH = frames.first().height - // Trim top 12% (status/app bar) and bottom 12% (nav bar) on subsequent frames so content stitches seamlessly - val cropTop = (singleH * 0.12f).toInt() + // Approximate the 44%-height swipe; arbitrary sticky/dynamic layouts cannot be perfectly stitched. + val cropTop = (singleH * 0.44f).toInt() val cropBottom = (singleH * 0.88f).toInt() val middleSliceH = (cropBottom - cropTop).coerceAtLeast(100) val totalHeight = singleH + (frames.size - 1) * middleSliceH @@ -852,7 +874,6 @@ class ScreenCaptureService : Service() { } bmp.recycle() } - frames.toMutableList().clear() return result } @@ -1013,6 +1034,10 @@ class ScreenCaptureService : Service() { pendingBitmap = null } releaseCaptureResources(stopProjection) + synchronized(longScreenshotFrames) { + longScreenshotFrames.forEach { if (!it.isRecycled) it.recycle() } + longScreenshotFrames.clear() + } releaseWakeLock() sessionActive = false isActive = false diff --git a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenKitAutoService.kt b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenKitAutoService.kt index 9189f63..a65fea9 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenKitAutoService.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenKitAutoService.kt @@ -31,7 +31,13 @@ class ScreenKitAutoService : AccessibilityService() { } override fun onAccessibilityEvent(event: AccessibilityEvent?) { - // ScreenKit does not read window content; it only dispatches user-started gestures. + // No event text is stored. Visible controls are queried only by opt-in smart scrolling. + } + + internal fun observePlayback(clickSkip: Boolean = false): com.screenkit.screenrecorder.automation.PlaybackObservation { + if (!AutoClickScrollOverlay.isExecuting || !SettingsStore.autoScrollSmartEnabled(this)) + return com.screenkit.screenrecorder.automation.PlaybackObservation() + return com.screenkit.screenrecorder.automation.PlaybackInspector.inspect(rootInActiveWindow, clickSkip) } override fun onInterrupt() { diff --git a/app/src/main/java/com/screenkit/screenrecorder/ui/AutoClickScrollOverlay.kt b/app/src/main/java/com/screenkit/screenrecorder/ui/AutoClickScrollOverlay.kt index 90a4312..8e04bfd 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/ui/AutoClickScrollOverlay.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/ui/AutoClickScrollOverlay.kt @@ -89,6 +89,13 @@ internal object AutoClickScrollOverlay { private var completedCycles = 0 private var completedScrolls = 0 private var executionStartedElapsedMs = 0L + private val scrollPolicy = com.screenkit.screenrecorder.automation.SmartScrollPolicy() + private var scrollHeld = false + private var scrollNextRequested = false + private var scrollAdvice = "" + private var executionGeneration = 0L + private var scrollGestureInFlight = false + private var scrollCooldownUntil = 0L private var scrollAnchorX = 0 private var scrollAnchorY = 0 @@ -305,6 +312,11 @@ internal object AutoClickScrollOverlay { completedCycles = 0 completedScrolls = 0 executionStartedElapsedMs = SystemClock.elapsedRealtime() + executionGeneration++ + scrollHeld = false; scrollNextRequested = false; scrollGestureInFlight = false + scrollCooldownUntil = 0L + scrollPolicy.reset(executionStartedElapsedMs) + scrollAdvice = "Waiting before first scroll…" setPinsTouchable(false) scrollGuideView?.invalidate() @@ -320,6 +332,8 @@ internal object AutoClickScrollOverlay { private fun stopExecutionInternal() { val wasRunning = isExecuting + executionGeneration++ + scrollGestureInFlight = false isExecuting = false mainHandler.removeCallbacks(tickRunnable) mainHandler.removeCallbacks(timerUiRunnable) @@ -421,7 +435,20 @@ internal object AutoClickScrollOverlay { } private fun runNextScrollStep(ctx: Context, autoService: ScreenKitAutoService) { - if (!isExecuting) return + if (!isExecuting || scrollGestureInFlight) return + val now = SystemClock.elapsedRealtime() + val smart = SettingsStore.autoScrollSmartEnabled(ctx) + val advice = scrollPolicy.decide(now, SettingsStore.autoScrollIntervalMs(ctx), smart, scrollHeld, + if (smart) autoService.observePlayback() else com.screenkit.screenrecorder.automation.PlaybackObservation()) + scrollAdvice = advice.message + if (!scrollNextRequested && (now < scrollCooldownUntil || advice.decision != com.screenkit.screenrecorder.automation.ScrollDecision.NEXT)) { + if (advice.decision == com.screenkit.screenrecorder.automation.ScrollDecision.SKIP_AD) autoService.observePlayback(clickSkip = true) + updateStatusBanner() + mainHandler.postDelayed(tickRunnable, 350L) + return + } + scrollNextRequested = false + val generation = executionGeneration val wm = windowManager ?: return val metrics = realDisplayMetrics(ctx, wm) val screenW = metrics.widthPixels.toFloat() @@ -430,7 +457,7 @@ internal object AutoClickScrollOverlay { val jitter = SettingsStore.autoRandomJitter(ctx) val direction = SettingsStore.autoScrollDirection(ctx) val speedMs = jitterDuration(SettingsStore.autoScrollSpeedMs(ctx), jitter) - val intervalMs = jitterDelay(SettingsStore.autoScrollIntervalMs(ctx), jitter) + // User-selected viewing time is exact; jitter applies only to the swipe, not the timer. val distFraction = (SettingsStore.autoScrollDistancePct(ctx) / 100f).coerceIn(0.20f, 0.85f) val anchorX = jitterCoord(scrollAnchorX.toFloat().coerceIn(screenW * 0.15f, screenW * 0.85f), jitter) @@ -466,7 +493,13 @@ internal object AutoClickScrollOverlay { scrollGuideView?.invalidate() updateStatusBanner() - autoService.performSwipe(startX, startY, endX, endY, speedMs) { + scrollGestureInFlight = true + autoService.performSwipe(startX, startY, endX, endY, speedMs) { success -> + if (generation != executionGeneration) return@performSwipe + scrollGestureInFlight = false + scrollPolicy.reset(SystemClock.elapsedRealtime()) + scrollCooldownUntil = SystemClock.elapsedRealtime() + 1000L + if (!success) scrollAdvice = "Swipe interrupted — waiting before retry" scrollGuideView?.isFiringNow = false scrollGuideView?.invalidate() if (!isExecuting) return@performSwipe @@ -479,7 +512,7 @@ internal object AutoClickScrollOverlay { return@performSwipe } updateStatusBanner() - mainHandler.postDelayed(tickRunnable, intervalMs) + mainHandler.postDelayed(tickRunnable, 350L) } } @@ -806,6 +839,19 @@ internal object AutoClickScrollOverlay { } } AutoToolMode.SCROLL -> { + if (isExecuting) { + btnRow.addView(makeBarPillButton(ctx, if (scrollHeld) "▶ Resume" else "⏸ Hold", 0xFF1F2D33.toInt(), Color.WHITE) { + scrollHeld = !scrollHeld + rebuildControlBar(ctx) + }) + btnRow.addView(makeBarPillButton(ctx, "Next ➜", 0xFF1F2D33.toInt(), Color.WHITE) { + scrollNextRequested = true + if (!scrollGestureInFlight) { + mainHandler.removeCallbacks(tickRunnable) + mainHandler.post(tickRunnable) + } + }) + } val dir = SettingsStore.autoScrollDirection(ctx) btnRow.addView( makeBarPillButton( @@ -933,7 +979,7 @@ internal object AutoClickScrollOverlay { } else { "${formatSeconds(elapsedSec)} (∞)" } - tv.text = "${dir.arrow} ${dir.label} · #$completedScrolls · $timeLabel" + tv.text = "${dir.arrow} #$completedScrolls · $timeLabel\n$scrollAdvice" } else { val durLabel = if (durSec == 0) "∞" else formatSeconds(durSec) tv.text = "${dir.arrow} ${dir.label} · ${speedMs}ms swipe · ${intervalMs}ms gap · $durLabel" @@ -1489,6 +1535,9 @@ internal object AutoClickScrollOverlay { 2500L to "2.5s", 4000L to "4s", 8000L to "8s", + 30000L to "30s", + 60000L to "60s", + 120000L to "120s", ) addOptionRow( ctx = ctx, @@ -1502,6 +1551,14 @@ internal object AutoClickScrollOverlay { populatePanel() } + col.addView(makeBarPillButton(ctx, "Custom seconds / Smart playback", 0xFF1F2D33.toInt(), Color.WHITE) { + if (isExecuting) stopExecutionInternal() + ScrollPreferencesDialog.show(ctx) { + rebuildControlBar(ctx) + ScreenKitAutoService.broadcastState(ctx) + populatePanel() + } + }) val curDur = SettingsStore.autoScrollDurationSec(ctx) val durOptions = listOf( 30 to "30s", diff --git a/app/src/main/java/com/screenkit/screenrecorder/ui/CaptureViewModel.kt b/app/src/main/java/com/screenkit/screenrecorder/ui/CaptureViewModel.kt index be138d3..8a3d8ff 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/ui/CaptureViewModel.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/ui/CaptureViewModel.kt @@ -58,6 +58,7 @@ internal data class CaptureUiState( val autoScrollDirection: AutoScrollDirection = AutoScrollDirection.DOWN, val autoScrollSpeedMs: Long = 450L, val autoScrollIntervalMs: Long = 1500L, + val autoScrollSmartEnabled: Boolean = false, val autoScrollDurationSec: Int = 120, val autoScrollDistancePct: Int = 55, val autoRandomJitter: Boolean = true, diff --git a/app/src/main/java/com/screenkit/screenrecorder/ui/CreatorToolsOverlay.kt b/app/src/main/java/com/screenkit/screenrecorder/ui/CreatorToolsOverlay.kt index 09f3123..8efc96a 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/ui/CreatorToolsOverlay.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/ui/CreatorToolsOverlay.kt @@ -58,7 +58,20 @@ internal object CreatorToolsOverlay { private var magnifierInfoTv: TextView? = null private var currentSampleX = 540 private var currentSampleY = 960 - private var currentSampleColor: Int = Color.parseColor("#4CAF50") + private var currentSampleColor: Int = Color.TRANSPARENT + private var sampleScreenWidth = 1 + private var sampleScreenHeight = 1 + private var sampledAt = 0L + private var feedContext: Context? = null + private fun freshPixels(): Boolean = latestScreenBitmap?.isRecycled == false && + android.os.SystemClock.elapsedRealtime() - sampledAt < 2000L + private val magnifierTicker = object : Runnable { + override fun run() { + if (!isMagnifierShowing) return + samplePixelAtReticle() + mainHandler.postDelayed(this, 500L) + } + } @Volatile var latestScreenBitmap: Bitmap? = null private set @@ -87,35 +100,26 @@ internal object CreatorToolsOverlay { } } - /** Called by ScreenCaptureService whenever a frame is captured so the Magnifier/Eyedropper has fresh pixels. */ - fun updateSampleBitmap(bitmap: Bitmap?) { - if (bitmap == null || bitmap.isRecycled) return - runCatching { - val copy = bitmap.copy(Bitmap.Config.ARGB_8888, false) - latestScreenBitmap = copy - mainHandler.post { - if (isMagnifierShowing) { - samplePixelAtReticle() - } - } - } + /** Main-thread ownership transfer: at most one live bitmap is retained, never screenshot history. */ + fun updateLiveFrame(bitmap: Bitmap, screenWidth: Int, screenHeight: Int) { + val previous = latestScreenBitmap + latestScreenBitmap = bitmap + sampleScreenWidth = screenWidth; sampleScreenHeight = screenHeight + sampledAt = android.os.SystemClock.elapsedRealtime() + previous?.takeUnless { it === bitmap || it.isRecycled }?.recycle() + if (isMagnifierShowing) samplePixelAtReticle() } - /** - * Checks if the pixel at `(x, y)` matches `targetHex` within RGB tolerance when a frame bitmap is available. - * If no frame bitmap has been captured yet, returns true so clicking is never blocked. - */ + /** Fail closed: a missing/stale frame must never pretend the requested color matched. */ fun doesScreenColorMatchIfAvailable(x: Int, y: Int, targetHex: String): Boolean { - val bmp = latestScreenBitmap ?: return true - if (bmp.isRecycled) return true - val targetColor = runCatching { Color.parseColor(targetHex) }.getOrNull() ?: return true - val sx = x.coerceIn(0, (bmp.width - 1).coerceAtLeast(0)) - val sy = y.coerceIn(0, (bmp.height - 1).coerceAtLeast(0)) - val pixel = runCatching { bmp.getPixel(sx, sy) }.getOrNull() ?: return true - val dr = abs(Color.red(pixel) - Color.red(targetColor)) - val dg = abs(Color.green(pixel) - Color.green(targetColor)) - val db = abs(Color.blue(pixel) - Color.blue(targetColor)) - return dr <= 38 && dg <= 38 && db <= 38 + if (!freshPixels()) return false + val bmp = latestScreenBitmap ?: return false + if (x !in 0 until sampleScreenWidth || y !in 0 until sampleScreenHeight) return false + val target = runCatching { Color.parseColor(targetHex) }.getOrNull() ?: return false + val pixel = bmp.getPixel((x.toLong() * bmp.width / sampleScreenWidth).toInt().coerceIn(0, bmp.width - 1), + (y.toLong() * bmp.height / sampleScreenHeight).toInt().coerceIn(0, bmp.height - 1)) + return abs(Color.red(pixel) - Color.red(target)) <= 38 && + abs(Color.green(pixel) - Color.green(target)) <= 38 && abs(Color.blue(pixel) - Color.blue(target)) <= 38 } fun setHiddenForScreenshot(hidden: Boolean) { @@ -279,12 +283,20 @@ internal object CreatorToolsOverlay { hideMagnifier() onChanged?.invoke() } else { - showMagnifier(context, onChanged) + if (!Settings.canDrawOverlays(context)) { + CaptureIntents.toast(context, "Allow display over other apps before using Zoom.") + return + } + val intent = android.content.Intent(context, com.screenkit.screenrecorder.CaptureRequestActivity::class.java) + .putExtra(com.screenkit.screenrecorder.CaptureContract.EXTRA_OVERLAY_COMMAND, com.screenkit.screenrecorder.CaptureContract.COMMAND_TOGGLE_MAGNIFIER) + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { context.startActivity(intent) }.onFailure { CaptureIntents.toast(context, "Open ScreenKit to approve live screen sharing for Zoom.") } } } fun showMagnifier(context: Context, onChanged: (() -> Unit)? = null) { val ctx = context.applicationContext + feedContext = ctx if (!Settings.canDrawOverlays(ctx)) { CaptureIntents.toast(ctx, "Allow ScreenKit to display over other apps first.") return @@ -338,6 +350,10 @@ internal object CreatorToolsOverlay { } btnRow.addView( makeSmallChip(ctx, "📋 Hex", 0xFF1E2B30.toInt(), 0xFFB7F36B.toInt()) { + if (!freshPixels()) { + CaptureIntents.toast(ctx, "No fresh screen pixels. Reopen Zoom and approve screen sharing.") + return@makeSmallChip + } val hex = String.format("#%06X", 0xFFFFFF and currentSampleColor) val cb = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager cb?.setPrimaryClip(ClipData.newPlainText("ScreenKit Color", hex)) @@ -346,6 +362,10 @@ internal object CreatorToolsOverlay { ) btnRow.addView( makeSmallChip(ctx, "🎯 AutoPin", 0xFFB7F36B.toInt(), 0xFF10170D.toInt()) { + if (!freshPixels()) { + CaptureIntents.toast(ctx, "No fresh screen pixels. Reopen Zoom and approve screen sharing.") + return@makeSmallChip + } val hex = String.format("#%06X", 0xFFFFFF and currentSampleColor) SettingsStore.setAutoColorTriggerHex(ctx, hex) SettingsStore.setAutoColorTriggerEnabled(ctx, true) @@ -362,6 +382,11 @@ internal object CreatorToolsOverlay { ) pill.addView(btnRow) root.addView(pill) + root.addView(TextView(ctx).apply { + text = "▼ target: 20dp below this panel · 3× live" + textSize = 9f; gravity = Gravity.CENTER; setTextColor(Color.WHITE) + setBackgroundColor(0xEE121A1F.toInt()) + }) val params = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, @@ -378,9 +403,8 @@ internal object CreatorToolsOverlay { } val drag = makeDragListener(ctx, root, params) { newX, newY -> - currentSampleX = newX + loupeSize / 2 - currentSampleY = newY + loupeSize / 2 - samplePixelAtReticle() + params.y = newY.coerceAtMost((metrics.heightPixels - root.height - dp(ctx, 24)).coerceAtLeast(0)) + root.post { samplePixelAtReticle() } } loupe.setOnTouchListener(drag) pill.setOnTouchListener(drag) @@ -390,44 +414,58 @@ internal object CreatorToolsOverlay { loupeCanvasView = loupe isMagnifierShowing = true samplePixelAtReticle() - runCatching { wm.addView(root, params) } + runCatching { wm.addView(root, params) }.onFailure { + hideMagnifier() + CaptureIntents.toast(ctx, "Zoom overlay could not open. Check display-over-apps permission.") + } + root.post { + params.y = params.y.coerceAtMost((metrics.heightPixels - root.height - dp(ctx, 24)).coerceAtLeast(0)) + runCatching { wm.updateViewLayout(root, params) } + samplePixelAtReticle() + } + mainHandler.removeCallbacks(magnifierTicker) + mainHandler.post(magnifierTicker) ScreenKitAutoService.broadcastState(ctx) onChanged?.invoke() } } private fun samplePixelAtReticle() { + magnifierView?.let { root -> + val location = IntArray(2) + root.getLocationOnScreen(location) + currentSampleX = location[0] + root.width / 2 + currentSampleY = location[1] + root.height + dp(root.context, 20) + } val bmp = latestScreenBitmap - if (bmp != null && !bmp.isRecycled) { - val sx = currentSampleX.coerceIn(0, (bmp.width - 1).coerceAtLeast(0)) - val sy = currentSampleY.coerceIn(0, (bmp.height - 1).coerceAtLeast(0)) - runCatching { currentSampleColor = bmp.getPixel(sx, sy) } - } else { - // Deterministic vivid color preview based on coordinates until a screenshot frame is sampled - val r = ((currentSampleX * 37) % 200) + 45 - val g = ((currentSampleY * 53) % 200) + 45 - val b = (((currentSampleX + currentSampleY) * 29) % 200) + 45 - currentSampleColor = Color.rgb(r, g, b) + if (!freshPixels() || bmp == null || currentSampleX !in 0 until sampleScreenWidth || currentSampleY !in 0 until sampleScreenHeight) { + currentSampleColor = Color.TRANSPARENT + magnifierInfoTv?.text = "Waiting for live screen pixels…\nProtected content may remain black." + loupeCanvasView?.invalidate() + return } + val sx = (currentSampleX.toLong() * bmp.width / sampleScreenWidth).toInt().coerceIn(0, bmp.width - 1) + val sy = (currentSampleY.toLong() * bmp.height / sampleScreenHeight).toInt().coerceIn(0, bmp.height - 1) + currentSampleColor = bmp.getPixel(sx, sy) val hex = String.format("#%06X", 0xFFFFFF and currentSampleColor) - val r = Color.red(currentSampleColor) - val g = Color.green(currentSampleColor) - val b = Color.blue(currentSampleColor) - magnifierInfoTv?.text = "X:$currentSampleX Y:$currentSampleY · $hex\nRGB($r, $g, $b)" + magnifierInfoTv?.text = "LIVE X:$currentSampleX Y:$currentSampleY · $hex\nRGB(${Color.red(currentSampleColor)}, ${Color.green(currentSampleColor)}, ${Color.blue(currentSampleColor)})" loupeCanvasView?.invalidate() } - fun hideMagnifier() { + fun hideMagnifier(stopFeed: Boolean = true) { mainHandler.post { - val wm = windowManager - val ctx = magnifierView?.context - magnifierView?.let { v -> runCatching { wm?.removeView(v) } } - magnifierView = null - magnifierParams = null - loupeCanvasView = null - magnifierInfoTv = null + val ctx = feedContext ?: magnifierView?.context + mainHandler.removeCallbacks(magnifierTicker) + magnifierView?.let { v -> runCatching { windowManager?.removeView(v) } } + magnifierView = null; magnifierParams = null; loupeCanvasView = null; magnifierInfoTv = null + latestScreenBitmap?.takeUnless { it.isRecycled }?.recycle() + latestScreenBitmap = null; sampledAt = 0L isMagnifierShowing = false - if (ctx != null) ScreenKitAutoService.broadcastState(ctx) + if (ctx != null) { + if (stopFeed) com.screenkit.screenrecorder.services.MagnifierCaptureService.stop(ctx) + ScreenKitAutoService.broadcastState(ctx) + } + feedContext = null } } @@ -456,13 +494,16 @@ internal object CreatorToolsOverlay { val radius = (width.coerceAtMost(height) * 0.44f) val bmp = latestScreenBitmap - if (bmp != null && !bmp.isRecycled) { - val halfSample = 14 + if (bmp != null && freshPixels()) { + val sx = currentSampleX * bmp.width.toFloat() / sampleScreenWidth + val sy = currentSampleY * bmp.height.toFloat() / sampleScreenHeight + val halfX = (radius / 3f * bmp.width / sampleScreenWidth).coerceAtLeast(1f) + val halfY = (radius / 3f * bmp.height / sampleScreenHeight).coerceAtLeast(1f) srcRect.set( - (currentSampleX - halfSample).coerceAtLeast(0), - (currentSampleY - halfSample).coerceAtLeast(0), - (currentSampleX + halfSample).coerceAtMost(bmp.width), - (currentSampleY + halfSample).coerceAtMost(bmp.height), + (sx - halfX).toInt().coerceIn(0, bmp.width - 1), + (sy - halfY).toInt().coerceIn(0, bmp.height - 1), + (sx + halfX).toInt().coerceIn(1, bmp.width), + (sy + halfY).toInt().coerceIn(1, bmp.height), ) dstRect.set( (cx - radius).toInt(), diff --git a/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt b/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt index 7794b06..9a17611 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt @@ -929,6 +929,7 @@ private fun AutoClickScrollCard( onLoadAutoProfile: (Int) -> Unit, onSaveAutoProfile: (Int) -> Unit, ) { + val context = LocalContext.current Card( shape = RoundedCornerShape(22.dp), colors = CardDefaults.cardColors(containerColor = Panel), @@ -1152,6 +1153,10 @@ private fun AutoClickScrollCard( 2500L to "2.5s", 4000L to "4s", 8000L to "8s", + 15000L to "15s", + 30000L to "30s", + 60000L to "60s", + 120000L to "120s", ) AutoSettingChipRow( title = "Pause delay between scrolls: ${state.autoScrollIntervalMs}ms", @@ -1160,6 +1165,16 @@ private fun AutoClickScrollCard( onSelect = { idx -> onAutoScrollIntervalChanged(scrollIntervals[idx].first) }, ) + OutlinedButton(onClick = { + ScrollPreferencesDialog.show(context) { + onAutoScrollIntervalChanged(com.screenkit.screenrecorder.data.SettingsStore.autoScrollIntervalMs(context)) + } + }) { Text("Custom seconds / Smart playback") } + Text( + if (state.autoScrollSmartEnabled) "Smart mode: visible playback/ad signals; timer fallback when unavailable. Hold / Next are on the floating scroll bar." + else "Timed mode · Custom delay up to 3600 seconds. Smart playback is opt-in.", + color = Muted, fontSize = 11.sp, + ) val scrollDurations = listOf( 30 to "30 sec", 60 to "1 min", diff --git a/app/src/main/java/com/screenkit/screenrecorder/ui/ScrollPreferencesDialog.kt b/app/src/main/java/com/screenkit/screenrecorder/ui/ScrollPreferencesDialog.kt new file mode 100644 index 0000000..fc5254c --- /dev/null +++ b/app/src/main/java/com/screenkit/screenrecorder/ui/ScrollPreferencesDialog.kt @@ -0,0 +1,58 @@ +package com.screenkit.screenrecorder.ui + +import android.app.Activity +import android.app.AlertDialog +import android.content.Context +import android.content.ContextWrapper +import android.os.Build +import android.provider.Settings +import android.text.InputType +import android.view.ContextThemeWrapper +import android.view.WindowManager +import android.widget.CheckBox +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import com.screenkit.screenrecorder.automation.ScrollDelay +import com.screenkit.screenrecorder.data.SettingsStore + +internal object ScrollPreferencesDialog { + fun show(context: Context, onSaved: () -> Unit) { + var base = context + while (base is ContextWrapper && base !is Activity) base = base.baseContext + val activity = base as? Activity + if (activity == null && !Settings.canDrawOverlays(context)) return + val themed = ContextThemeWrapper(activity ?: context, android.R.style.Theme_Material_Dialog_Alert) + val pad = (20 * context.resources.displayMetrics.density).toInt() + val layout = LinearLayout(themed).apply { orientation = LinearLayout.VERTICAL; setPadding(pad, pad, pad, 0) } + layout.addView(TextView(themed).apply { text = "Wait before each scroll, in seconds (0.2–3600). This also applies to the first video." }) + val input = EditText(themed).apply { + inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL + setSingleLine(true) + setText((SettingsStore.autoScrollIntervalMs(context) / 1000.0).toString()) + selectAll() + } + layout.addView(input) + val smart = CheckBox(themed).apply { text = "Smart playback / ads (experimental)"; isChecked = SettingsStore.autoScrollSmartEnabled(context) } + layout.addView(smart) + layout.addView(TextView(themed).apply { + text = "Smart mode reads visible playback/ad labels in YouTube, Instagram and Facebook while Auto Scroll runs. Processing stays on this device; text is not saved or sent. It can use a visible Skip ad button, skip labelled sponsored feed items, and hold during recognised in-video ad breaks. It cannot bypass unskippable ads or reliably detect hidden/localised controls. Without playback info it uses your timer. Use Hold / Next when needed.\n\nSet session duration to Infinite if your delay is longer than the run time." + textSize = 12f + }) + val dialog = AlertDialog.Builder(themed).setTitle("Custom scroll timing") + .setView(android.widget.ScrollView(themed).apply { addView(layout) }).setPositiveButton("Save", null).setNegativeButton("Cancel", null).create() + if (activity == null) dialog.window?.setType(if (Build.VERSION.SDK_INT >= 26) WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY else WindowManager.LayoutParams.TYPE_PHONE) + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val ms = ScrollDelay.parseSeconds(input.text.toString()) + if (ms == null) input.error = "Enter 0.2–3600 seconds (for example 30, 90 or 300)." + else { + SettingsStore.setAutoScrollIntervalMs(context, ms) + SettingsStore.setAutoScrollSmartEnabled(context, smart.isChecked) + onSaved(); dialog.dismiss() + } + } + } + dialog.show() + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8108104..6f2b909 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ ScreenKit ScreenKit Auto Click & Scroll - Allows ScreenKit to perform user-configured sequential auto-clicking (1 → 2 → 3 → 4…) and auto-scrolling (up, down, left, right) gestures on screen. + Run gestures you start. Optional Smart Scroll reads visible playback and ad controls on-device to wait for videos and ad breaks. Screen content is not stored or transmitted. Enable only if you trust ScreenKit. diff --git a/app/src/main/res/xml/auto_accessibility_service.xml b/app/src/main/res/xml/auto_accessibility_service.xml index 0841f11..f1888c0 100644 --- a/app/src/main/res/xml/auto_accessibility_service.xml +++ b/app/src/main/res/xml/auto_accessibility_service.xml @@ -2,10 +2,10 @@ diff --git a/app/src/test/java/com/screenkit/screenrecorder/automation/SmartScrollPolicyTest.kt b/app/src/test/java/com/screenkit/screenrecorder/automation/SmartScrollPolicyTest.kt new file mode 100644 index 0000000..bd084d0 --- /dev/null +++ b/app/src/test/java/com/screenkit/screenrecorder/automation/SmartScrollPolicyTest.kt @@ -0,0 +1,84 @@ +package com.screenkit.screenrecorder.automation + +import org.junit.Assert.* +import org.junit.Test + +class SmartScrollPolicyTest { + private val content = PlaybackObservation("com.facebook.katana", supported = true) + private fun tick(policy: SmartScrollPolicy, now: Long, observation: PlaybackObservation = content, delay: Long = 30_000, + smart: Boolean = true, hold: Boolean = false) = policy.decide(now, delay, smart, hold, observation) + + @Test fun customDelayAllowsMinutesAndRejectsInvalidOrOverflow() { + assertEquals(90_000L, ScrollDelay.parseSeconds("90")) + assertEquals(3_600_000L, ScrollDelay.parseSeconds("3600")) + assertEquals(250L, ScrollDelay.parseSeconds("0.25")) + listOf("", "NaN", "Infinity", "1e99", "-1", "0", "3601", "abc").forEach { assertNull(ScrollDelay.parseSeconds(it)) } + } + @Test fun timedModeWaitsBeforeFirstSwipeAndSupportsLongDelays() { + val p = SmartScrollPolicy().apply { reset(0) } + for (second in 1..89) assertEquals(ScrollDecision.WAIT, tick(p, second * 1000L, delay = 90_000, smart = false).decision) + assertEquals(ScrollDecision.NEXT, tick(p, 90_000, delay = 90_000, smart = false).decision) + } + @Test fun holdDoesNotSpendViewingTime() { + val p = SmartScrollPolicy().apply { reset(0) } + for (second in 1..20) assertEquals(ScrollDecision.WAIT, tick(p, second * 1000L, delay = 1000, smart = false, hold = true).decision) + assertEquals(ScrollDecision.NEXT, tick(p, 21_000, delay = 1000, smart = false).decision) + } + @Test fun midrollDoesNotSwipeOrTreatProgressResetAsContentLoop() { + val p = SmartScrollPolicy().apply { reset(0) } + tick(p, 350, content.copy(progress = 0.8)) + for (second in 1..40) assertEquals(ScrollDecision.WAIT, tick(p, second * 1000L, content.copy(adBreak = true, progress = 0.02), delay = 8000).decision) + assertEquals(ScrollDecision.WAIT, tick(p, 41_000, content.copy(progress = 0.05), delay = 8000).decision) + assertEquals(ScrollDecision.WAIT, tick(p, 43_000, content.copy(progress = 0.82), delay = 8000).decision) + } + @Test fun visibleVideoProgressOverridesShortFixedTimer() { + val p = SmartScrollPolicy().apply { reset(0) } + tick(p, 100, content.copy(progress = 0.01)) + for (second in 1..20) assertEquals(ScrollDecision.WAIT, tick(p, second * 1000L, content.copy(progress = second / 30.0), delay = 1000).decision) + } + @Test fun advancesAtEndOrAfterLoopNotAtInitialZero() { + val p = SmartScrollPolicy().apply { reset(0) } + tick(p, 100, content.copy(progress = 0.0)) + tick(p, 1100, content.copy(progress = 0.7)) + assertEquals(ScrollDecision.NEXT, tick(p, 2100, content.copy(progress = 0.99)).decision) + p.reset(3000) + tick(p, 3100, content.copy(progress = 0.0)) + tick(p, 4100, content.copy(progress = 0.8)) + assertEquals(ScrollDecision.NEXT, tick(p, 5100, content.copy(progress = 0.02)).decision) + } + @Test fun skipsOnlyLabelledFeedAdsAndVisibleSkipCommands() { + val p = SmartScrollPolicy().apply { reset(0) } + tick(p, 100, content.copy(sponsoredFeed = true)) + tick(p, 1100, content.copy(sponsoredFeed = true)) + tick(p, 2100, content.copy(sponsoredFeed = true)) + assertEquals(ScrollDecision.NEXT, tick(p, 2450, content.copy(sponsoredFeed = true)).decision) + p.reset(3000) + assertEquals(ScrollDecision.SKIP_AD, tick(p, 3100, content.copy(adBreak = true, skipAvailable = true)).decision) + assertEquals(ScrollDecision.WAIT, tick(p, 3450, content.copy(adBreak = true, skipAvailable = true)).decision) + assertEquals(ScrollDecision.WAIT, tick(p, 6000, content.copy(adBreak = true, sponsoredFeed = true)).decision) + } + @Test fun unsupportedAppNeverGetsAnAutomaticSmartSwipe() { + val p = SmartScrollPolicy().apply { reset(0) } + for (second in 1..30) assertEquals(ScrollDecision.WAIT, tick(p, second * 1000L, PlaybackObservation(), delay = 200).decision) + } + @Test fun missingProgressUsesHonestTimedFallback() { + val p = SmartScrollPolicy().apply { reset(0) } + tick(p, 100) + tick(p, 1100, delay = 2000) + assertEquals(ScrollDecision.NEXT, tick(p, 2100, delay = 2000).decision) + p.reset(2200) + assertTrue(tick(p, 2300).message.contains("No playback info")) + } + @Test fun parserAvoidsSubstringAdsAndHandlesExplicitTimes() { + assertTrue(PlaybackLabels.adBreak("Ad 1 of 2")) + assertTrue(PlaybackLabels.adBreak("Ad · 0:15")) + assertTrue(PlaybackLabels.skip("Skip ads")) + assertFalse(PlaybackLabels.skip("Skip intro")) + assertFalse(PlaybackLabels.adBreak("I hate this ad")) + assertFalse(PlaybackLabels.sponsored("A discussion about sponsored videos")) + assertEquals(0.5, PlaybackLabels.progress("0:15 / 0:30")!!, 0.0001) + assertEquals(0.5, PlaybackLabels.progress("1:00:00 of 2:00:00")!!, 0.0001) + assertNull(PlaybackLabels.progress("0:99 / 1:00")) + assertNull(PlaybackLabels.progress("10 / 100 likes")) + } +} diff --git a/docs/android-2.1.1-testing.md b/docs/android-2.1.1-testing.md new file mode 100644 index 0000000..23add6c --- /dev/null +++ b/docs/android-2.1.1-testing.md @@ -0,0 +1,28 @@ +# Android 2.1.1 regression checklist + +Automated: `:app:testDebugUnitTest` covers custom delay bounds, first-item wait, Hold, +end/loop detection, long playback vs short timers, sponsored vs mid-roll priority, +skip-button debounce, unsupported apps, timer fallback, and label/time parsing. +These are policy tests with synthetic observations, not proof that a social app exposes them. + +Physical Android tests (not available in the build sandbox): +- Bubble third row Long SS at both screen edges and small displays; permission denial, + Accessibility disabled/disconnected, static web/article, end of page, interrupted swipe. + Confirm overlays are absent, no early single-frame fallback, no duplicate end-page images, + and test approximate stitching with sticky headers. Capture at high display resolution. +- Zoom on Android 7–13 and 14+: grant/deny entire-screen sharing, drag panel across a + changing clock/video/text (not ScreenKit's own panel), compare swatch to known color blocks; + expect scaling interpolation. Copy/AutoPin unavailable before a fresh feed. Rotation, + screen lock, service stop, close button, overlay dismissal and permission revocation must + release the projection and clear pixels. Concurrent recording/capture is rejected. +- Delay 0.2, 8, 30, 90, 300, 3600 seconds in app and overlay; invalid/blank/overflow; + profile save/load custom interval; first-item delay; runtime finite vs Infinite. +- Smart enabled and disabled, Hold/Resume, Next during Hold, stop/restart mid-swipe; + switching apps, timer expiry during an ad, accessibility revoked. No old gesture callback + should schedule a new run's swipe. Disabling smart stops reading content. +- YouTube Shorts / Instagram / Facebook Reels: record exact app versions and languages; + short loop, long video, paused video, sponsored feed, visible Skip ad, unskippable mid-roll + and resumed content. Verify the banner reflects observable state. Hidden progress or + unknown/localised ad labels use the timer/manual controls, not fabricated detection. +- Regression: local MP4 (silent/mic/device/mixed), screenshots, Go Live start/stop; + all capture-mode service actions must route correctly after the dispatch fix. From c314ad3ceefb3df11b40d938d587001aa0f1dcfc Mon Sep 17 00:00:00 2001 From: sharthak18 <157966738+sharthak18@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:03:11 +0000 Subject: [PATCH 07/10] Refine scroll controls and long screenshot slice boundaries Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../screenrecorder/automation/PlaybackInspector.kt | 5 ++++- .../screenrecorder/services/ScreenCaptureService.kt | 10 ++++++++-- .../screenrecorder/ui/AutoClickScrollOverlay.kt | 8 ++++++-- .../screenkit/screenrecorder/ui/CreatorToolsOverlay.kt | 1 + 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/screenkit/screenrecorder/automation/PlaybackInspector.kt b/app/src/main/java/com/screenkit/screenrecorder/automation/PlaybackInspector.kt index f00d35b..6157f40 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/automation/PlaybackInspector.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/automation/PlaybackInspector.kt @@ -46,7 +46,10 @@ internal object PlaybackInspector { } } } - for (i in 0 until node.childCount.coerceAtMost(40)) node.getChild(i)?.let(stack::addLast) + for (i in 0 until node.childCount.coerceAtMost(40)) { + if (SystemClock.uptimeMillis() >= deadline || nodes + stack.size >= 250) break + node.getChild(i)?.let(stack::addLast) + } } finally { node.recycle() } } } finally { while (stack.isNotEmpty()) stack.removeFirst().recycle() } diff --git a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenCaptureService.kt b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenCaptureService.kt index 036d0c9..26c0b10 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/services/ScreenCaptureService.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/services/ScreenCaptureService.kt @@ -864,14 +864,20 @@ class ScreenCaptureService : Service() { var currentY = 0 frames.forEachIndexed { idx, bmp -> if (idx == 0) { - canvas.drawBitmap(bmp, 0f, 0f, null) - currentY += singleH + canvas.drawBitmap(bmp, android.graphics.Rect(0, 0, width, cropBottom), + android.graphics.Rect(0, 0, width, cropBottom), null) + currentY += cropBottom } else { val src = android.graphics.Rect(0, cropTop, width, cropBottom.coerceAtMost(bmp.height)) val dst = android.graphics.Rect(0, currentY, width, currentY + middleSliceH) canvas.drawBitmap(bmp, src, dst, null) currentY += middleSliceH } + if (idx == frames.lastIndex) { + val footer = singleH - cropBottom + canvas.drawBitmap(bmp, android.graphics.Rect(0, cropBottom, width, singleH), + android.graphics.Rect(0, currentY, width, currentY + footer), null) + } bmp.recycle() } return result diff --git a/app/src/main/java/com/screenkit/screenrecorder/ui/AutoClickScrollOverlay.kt b/app/src/main/java/com/screenkit/screenrecorder/ui/AutoClickScrollOverlay.kt index 8e04bfd..efb91a4 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/ui/AutoClickScrollOverlay.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/ui/AutoClickScrollOverlay.kt @@ -786,6 +786,8 @@ internal object AutoClickScrollOverlay { val statusTv = TextView(ctx).apply { textSize = 10.5f + maxWidth = (realDisplayMetrics(ctx, wm).widthPixels - dp(ctx, 48)).coerceAtLeast(dp(ctx, 180)) + maxLines = 3 setTextColor(0xFFE6F0EC.toInt()) typeface = Typeface.create("sans-serif-medium", Typeface.BOLD) } @@ -840,17 +842,19 @@ internal object AutoClickScrollOverlay { } AutoToolMode.SCROLL -> { if (isExecuting) { - btnRow.addView(makeBarPillButton(ctx, if (scrollHeld) "▶ Resume" else "⏸ Hold", 0xFF1F2D33.toInt(), Color.WHITE) { + val manualRow = LinearLayout(ctx).apply { orientation = LinearLayout.HORIZONTAL } + manualRow.addView(makeBarPillButton(ctx, if (scrollHeld) "▶ Resume" else "⏸ Hold", 0xFF1F2D33.toInt(), Color.WHITE) { scrollHeld = !scrollHeld rebuildControlBar(ctx) }) - btnRow.addView(makeBarPillButton(ctx, "Next ➜", 0xFF1F2D33.toInt(), Color.WHITE) { + manualRow.addView(makeBarPillButton(ctx, "Next ➜", 0xFF1F2D33.toInt(), Color.WHITE) { scrollNextRequested = true if (!scrollGestureInFlight) { mainHandler.removeCallbacks(tickRunnable) mainHandler.post(tickRunnable) } }) + bar.addView(manualRow) } val dir = SettingsStore.autoScrollDirection(ctx) btnRow.addView( diff --git a/app/src/main/java/com/screenkit/screenrecorder/ui/CreatorToolsOverlay.kt b/app/src/main/java/com/screenkit/screenrecorder/ui/CreatorToolsOverlay.kt index 8efc96a..bd602c9 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/ui/CreatorToolsOverlay.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/ui/CreatorToolsOverlay.kt @@ -128,6 +128,7 @@ internal object CreatorToolsOverlay { val vis = if (hidden) View.GONE else View.VISIBLE teleprompterView?.visibility = vis magnifierView?.visibility = vis + watermarkView?.visibility = vis } } From b3ae264f63e0a11ff581e1d5f0b5616714c83d8e Mon Sep 17 00:00:00 2001 From: sharthak18 <157966738+sharthak18@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:52:09 +0000 Subject: [PATCH 08/10] Remove floating Zoom and improve visible ad-control handling Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/android-apk.yml | 4 +- README.md | 22 +++++++-- app/build.gradle.kts | 4 +- .../automation/PlaybackInspector.kt | 30 ++++++++++-- .../automation/SmartScrollPolicy.kt | 29 ++++++++---- .../services/FloatingOverlayService.kt | 6 --- .../automation/SmartScrollPolicyTest.kt | 46 +++++++++++++++++++ docs/android-2.1.2-testing.md | 18 ++++++++ 8 files changed, 134 insertions(+), 25 deletions(-) create mode 100644 docs/android-2.1.2-testing.md diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 80103e0..9103d82 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -99,8 +99,8 @@ jobs: - name: Publish APK to GitHub Release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.1.1' }} - name: ScreenKit ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.1.1' }} + tag_name: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.1.2' }} + name: ScreenKit ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.1.2' }} target_commitish: ${{ github.sha }} make_latest: true generate_release_notes: true diff --git a/README.md b/README.md index 229f8c4..1f55a17 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ScreenKit (v2.1.1) +# ScreenKit (v2.1.2) A native, high-performance Android capture, creator studio & screen automation suite built with Modern Android Architecture (Jetpack Compose, CameraX, Foreground Services, Accessibility Gesture Engine, MediaProjection, OpenGL ES, MediaMuxer/MediaExtractor, and MediaCodec). ScreenKit v2.1.0 adds **Go Live screen streaming to YouTube, Facebook, and custom RTMP/RTMPS destinations** alongside screen recording with **Pause & Resume**, custom area cropping, **Scrolling Long Screenshots ("Stitch Full Page")**, built-in **MP4 Video Trimmer, Animated GIF Maker & PNG Frame Extractor**, sequential multi-target **Auto Clicking (`1 → 2 → 3 → 4…`)**, directional **Auto Scrolling (`↑ / ↓ / ← / →`)**, **Touch Macro Recorder & Replay**, **Saved Auto-Clicker Profiles (Slots 1–3)**, **Anti-Detection Human Jitter**, **Smart Color / Pixel Trigger (`#HEX`)**, **Floating Teleprompter**, **On-Screen Magnifier Loupe & Color Eyedropper**, **Creator Watermark Badge**, **Shake / Volume-Down Emergency Stop**, **Live Usage Analytics & One-Tap In-App Updater**, live interactive brush annotation, on-screen touch visualization, a draggable floating camera lens, amplified sound recording (Mic / Device Audio / Mixed), and a built-in Black-Screen Bypass Web Capture Browser. @@ -14,9 +14,9 @@ Android-only automation/overlay and protected-capture features are not ported. ## Download and Install -[⬇️ **Download the latest ScreenKit v2.1.1 APK**](https://github.com/sharthak18/ScreenKit/releases/latest/download/ScreenKit.apk) +[⬇️ **Download the latest ScreenKit v2.1.2 APK**](https://github.com/sharthak18/ScreenKit/releases/latest/download/ScreenKit.apk) -- **Current Version**: `v2.1.1` (`versionCode = 9`) +- **Current Version**: `v2.1.2` (`versionCode = 10`) - Compatible with **Android 7.0 (API 24) to Android 15 (API 35)**. - Device Audio & Mixed (`Mic + Device`) playback capture supported on **Android 10+**. - Full screen scope and single-app exclusion selection supported on **Android 14+**. @@ -24,6 +24,22 @@ Android-only automation/overlay and protected-capture features are not ported. --- +## Updated in v2.1.2 + +- Removed **Zoom** from the floating menu; the underlying magnifier is not deleted. + **Long SS** remains on the third row. +- Smart playback now looks for the button immediately around visible **Skip ad** text + when the text itself is not clickable. Searches remain package-scoped, visible-only, + time-bounded, and reject scrollable/broad ancestor containers. +- Ad breaks require two seconds of stable ad-label absence before restarting the content + timer. Brief gaps between consecutive ads do not spend the next video's viewing time. + Hold or an unknown foreground window restarts this confirmation. +- No root, VPN, DNS interception, TLS interception, or new permissions added. This is + visible-control assistance, **not universal ad blocking**. Network filters can block + separate ad domains but cannot reliably separate ads and video on shared endpoints. +- Automated policy tests cover ad-pod flicker, resumed-content timing, interrupted + confirmation, and skip cooldown. Social-app/device behavior still requires physical testing. + ## Fixed in v2.1.1 - **Floating Long Screenshot:** open the bubble and tap **Long SS** on its third row. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8754b7a..61198ad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.screenkit.screenrecorder" minSdk = 24 targetSdk = 34 - versionCode = 9 - versionName = "2.1.1" + versionCode = 10 + versionName = "2.1.2" } compileOptions { diff --git a/app/src/main/java/com/screenkit/screenrecorder/automation/PlaybackInspector.kt b/app/src/main/java/com/screenkit/screenrecorder/automation/PlaybackInspector.kt index 6157f40..52577d5 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/automation/PlaybackInspector.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/automation/PlaybackInspector.kt @@ -6,6 +6,25 @@ import android.os.SystemClock /** On-device, bounded inspection, invoked only while user-enabled smart scrolling is running. */ internal object PlaybackInspector { private val packages = setOf("com.google.android.youtube", "com.instagram.android", "com.facebook.katana", "com.facebook.lite") + /** Some apps expose the Skip ad text as a child of the actual button. Never click a broad feed container. */ + @Suppress("DEPRECATION") + private fun skipTarget(node: AccessibilityNodeInfo, app: String, deadline: Long): AccessibilityNodeInfo? { + var candidate: AccessibilityNodeInfo? = AccessibilityNodeInfo.obtain(node) + repeat(3) { depth -> + val current = candidate ?: return null + if (SystemClock.uptimeMillis() >= deadline || !current.isVisibleToUser || + !current.isEnabled || current.packageName?.toString() != app || + (depth > 0 && (current.childCount > 5 || current.isScrollable))) { + current.recycle() + return null + } + if (current.isClickable) return current // Caller owns this copy. + candidate = if (depth < 2) current.parent else null + current.recycle() + } + return null + } + @Suppress("DEPRECATION") fun inspect(root: AccessibilityNodeInfo?, clickSkip: Boolean = false): PlaybackObservation { root ?: return PlaybackObservation() @@ -40,9 +59,14 @@ internal object PlaybackInspector { labels.filter { it.length <= 70 }.forEach { ad = ad || PlaybackLabels.adBreak(it) sponsored = sponsored || PlaybackLabels.sponsored(it) - if (PlaybackLabels.skip(it) && node.isClickable && node.isEnabled) { - skip = true - if (clickSkip && !clicked) clicked = node.performAction(AccessibilityNodeInfo.ACTION_CLICK) + if (PlaybackLabels.skip(it)) { + val target = skipTarget(node, app, deadline) + if (target != null) { + try { + skip = true + if (clickSkip && !clicked) clicked = target.performAction(AccessibilityNodeInfo.ACTION_CLICK) + } finally { target.recycle() } + } } } } diff --git a/app/src/main/java/com/screenkit/screenrecorder/automation/SmartScrollPolicy.kt b/app/src/main/java/com/screenkit/screenrecorder/automation/SmartScrollPolicy.kt index f61a803..16d7e3f 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/automation/SmartScrollPolicy.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/automation/SmartScrollPolicy.kt @@ -20,27 +20,33 @@ internal class SmartScrollPolicy { private var progressSeenAt = 0L private var app = "" private var skipAttemptAt = -10_000L - private var resumedAfterAdAt = -10_000L + private var adClearSince: Long? = null private var inAd = false fun reset(now: Long) { lastTick = now; watchMs = 0; previousProgress = null; progressSeenAt = 0 - skipAttemptAt = -10_000L; resumedAfterAdAt = -10_000L; inAd = false; app = "" + skipAttemptAt = -10_000L; adClearSince = null; inAd = false; app = "" } fun decide(now: Long, intervalMs: Long, smart: Boolean, hold: Boolean, observation: PlaybackObservation): ScrollAdvice { val elapsed = (now - lastTick).coerceIn(0, 1000) lastTick = now - if (hold) return ScrollAdvice(ScrollDecision.WAIT, "HOLD · tap Resume or Next") + if (hold) { + adClearSince = null + return ScrollAdvice(ScrollDecision.WAIT, "HOLD · tap Resume or Next") + } if (!smart) { watchMs += elapsed return timed(intervalMs) } - if (!observation.supported) return ScrollAdvice(ScrollDecision.WAIT, "Smart: open YouTube, Instagram or Facebook") + if (!observation.supported) { + adClearSince = null + return ScrollAdvice(ScrollDecision.WAIT, "Smart: open YouTube, Instagram or Facebook") + } if (app != observation.packageName) { reset(now); app = observation.packageName } if (observation.adBreak || observation.skipAvailable) { - inAd = true + inAd = true; adClearSince = null // A skip command is only a click on a currently visible, labelled Skip ad button. if (observation.skipAvailable && now - skipAttemptAt >= 3000) { skipAttemptAt = now @@ -49,11 +55,16 @@ internal class SmartScrollPolicy { return ScrollAdvice(ScrollDecision.WAIT, "Ad break · holding this video until it resumes") } if (inAd) { - inAd = false; resumedAfterAdAt = now; watchMs = 0 - previousProgress = null; progressSeenAt = 0 + // Require a stable absence of ad controls: labels can disappear between ads in a pod. + val clearSince = adClearSince + if (clearSince == null) adClearSince = now + else if (now - clearSince >= 2000) { + inAd = false; adClearSince = null; watchMs = 0 + previousProgress = null; progressSeenAt = 0 + } + // Never count the confirmation interval as content viewing time or infer a loop from it. + return ScrollAdvice(ScrollDecision.WAIT, "Content resuming after ad…") } - // Don't interpret an ad-to-content progress jump or a briefly lingering sponsor label as an end. - if (now - resumedAfterAdAt < 2000) return ScrollAdvice(ScrollDecision.WAIT, "Content resuming after ad…") if (observation.sponsoredFeed && watchMs >= 1200) return ScrollAdvice(ScrollDecision.NEXT, "Skipping labelled sponsored feed item") if (observation.paused) return ScrollAdvice(ScrollDecision.WAIT, "Video paused · waiting for playback") watchMs += elapsed diff --git a/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt b/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt index ba59531..115605b 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt @@ -537,12 +537,6 @@ class FloatingOverlayService : Service() { MenuAction("✎", "Brush", CaptureContract.COMMAND_OPEN_BRUSH, brushActive), MenuAction("⊙", "Touch", CaptureContract.COMMAND_TOGGLE_TOUCHES, touchesActive), MenuAction("⇅", "Scroll", CaptureContract.COMMAND_OPEN_AUTO_SCROLL, autoScrollActive), - MenuAction( - "🔍", - "Zoom", - CaptureContract.COMMAND_TOGGLE_MAGNIFIER, - CreatorToolsOverlay.isMagnifierShowing, - ), MenuAction( "▤", "Script", diff --git a/app/src/test/java/com/screenkit/screenrecorder/automation/SmartScrollPolicyTest.kt b/app/src/test/java/com/screenkit/screenrecorder/automation/SmartScrollPolicyTest.kt index bd084d0..c80d16b 100644 --- a/app/src/test/java/com/screenkit/screenrecorder/automation/SmartScrollPolicyTest.kt +++ b/app/src/test/java/com/screenkit/screenrecorder/automation/SmartScrollPolicyTest.kt @@ -31,6 +31,52 @@ class SmartScrollPolicyTest { assertEquals(ScrollDecision.WAIT, tick(p, 41_000, content.copy(progress = 0.05), delay = 8000).decision) assertEquals(ScrollDecision.WAIT, tick(p, 43_000, content.copy(progress = 0.82), delay = 8000).decision) } + @Test fun adPodFlickerDoesNotConsumeContentTimer() { + val p = SmartScrollPolicy().apply { reset(0) } + tick(p, 100, content.copy(adBreak = true), delay = 200) + // Short gaps between ads must not trigger a swipe, even with the shortest timer. + for (time in listOf(450L, 800L, 1150L)) { + assertEquals(ScrollDecision.WAIT, tick(p, time, delay = 200).decision) + } + assertEquals(ScrollDecision.WAIT, tick(p, 1500, content.copy(adBreak = true), delay = 200).decision) + for (time in listOf(1850L, 2850L, 3850L)) { + assertEquals(ScrollDecision.WAIT, tick(p, time, delay = 200).decision) + } + assertEquals(ScrollDecision.NEXT, tick(p, 4200, delay = 200).decision) + } + + @Test fun resumedContentGetsFullDelayAfterConfirmation() { + val p = SmartScrollPolicy().apply { reset(0) } + tick(p, 100, content.copy(adBreak = true), delay = 3000) + for (time in listOf(450L, 1450L, 2450L, 3450L, 4450L)) { + assertEquals(ScrollDecision.WAIT, tick(p, time, delay = 3000).decision) + } + assertEquals(ScrollDecision.NEXT, tick(p, 5450, delay = 3000).decision) + } + + @Test fun manualHoldAndUnknownWindowCannotConfirmAdEnded() { + for (manualHold in listOf(true, false)) { + val p = SmartScrollPolicy().apply { reset(0) } + tick(p, 100, content.copy(adBreak = true), delay = 200) + tick(p, 450, delay = 200) + tick(p, 5000, if (manualHold) content else PlaybackObservation(), delay = 200, hold = manualHold) + // Coming back starts a fresh confirmation, not an immediate content timer. + for (time in listOf(5350L, 5700L, 6700L, 7700L)) { + assertEquals(ScrollDecision.WAIT, tick(p, time, delay = 200).decision) + } + assertEquals(ScrollDecision.NEXT, tick(p, 8050, delay = 200).decision) + } + } + + @Test fun skipButtonDuringAdPodStillUsesCooldown() { + val p = SmartScrollPolicy().apply { reset(0) } + val skip = content.copy(skipAvailable = true) + assertEquals(ScrollDecision.SKIP_AD, tick(p, 100, skip).decision) + tick(p, 450) + assertEquals(ScrollDecision.WAIT, tick(p, 800, skip).decision) + assertEquals(ScrollDecision.SKIP_AD, tick(p, 3100, skip).decision) + } + @Test fun visibleVideoProgressOverridesShortFixedTimer() { val p = SmartScrollPolicy().apply { reset(0) } tick(p, 100, content.copy(progress = 0.01)) diff --git a/docs/android-2.1.2-testing.md b/docs/android-2.1.2-testing.md new file mode 100644 index 0000000..f953afc --- /dev/null +++ b/docs/android-2.1.2-testing.md @@ -0,0 +1,18 @@ +# Android 2.1.2 regression checklist + +Automated policy cases: ad-label flicker between consecutive ads with a 0.2s timer; +full viewing delay after two-second ad-end confirmation; Hold and unsupported-window +interruptions; visible Skip-ad cooldown. Existing policy/parser and streaming tests remain. + +Physical-device checks (not run in the sandbox): +- Floating menu has no Zoom action; Long SS, Script, Badge, scrolling and recording remain usable. +- Smart off performs no player inspection; Smart on can use a visible Skip ad label + on the button itself or within up to two small non-scrollable parent containers. +- Hidden, disabled, different-package, broad and scrollable parents must not be clicked. +- YouTube/Facebook ad pods: flickering labels must not trigger Next; after stable + disappearance, content receives a fresh timer. Test long mid-roll ads and resumption. +- Skip cooldown, Hold/Resume, manual Next, app switching and accessibility loss. +- No root/network filtering or new Android permissions; unskippable ads remain unskippable. + +These heuristics cannot identify every app version, language, or ad. Hidden ad controls +may still cause timed fallback. Use manual Hold/Next when detection is unavailable. From 347be06a30510d6a36b2017ee770f8a849d9ea16 Mon Sep 17 00:00:00 2001 From: sharthak18 <157966738+sharthak18@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:14:46 +0000 Subject: [PATCH 09/10] Add opt-in DNS VPN ad filtering tied to floating controls Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/android-apk.yml | 4 +- README.md | 39 ++- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 21 ++ app/src/main/assets/ad-domains.txt | 32 +++ .../adblock/AdBlockConsentActivity.kt | 72 ++++++ .../screenrecorder/adblock/AdBlockState.kt | 33 +++ .../adblock/AdBlockVpnService.kt | 239 ++++++++++++++++++ .../screenrecorder/adblock/DnsMessage.kt | 76 ++++++ .../adblock/DnsPacketGateway.kt | 155 ++++++++++++ .../screenrecorder/adblock/DnsPackets.kt | 77 ++++++ .../services/FloatingOverlayService.kt | 4 + .../screenrecorder/ui/AdBlockCard.kt | 47 ++++ .../screenkit/screenrecorder/ui/MainScreen.kt | 2 + .../screenrecorder/adblock/DnsFilterTest.kt | 178 +++++++++++++ docs/android-dns-filter.md | 89 +++++++ 16 files changed, 1065 insertions(+), 7 deletions(-) create mode 100644 app/src/main/assets/ad-domains.txt create mode 100644 app/src/main/java/com/screenkit/screenrecorder/adblock/AdBlockConsentActivity.kt create mode 100644 app/src/main/java/com/screenkit/screenrecorder/adblock/AdBlockState.kt create mode 100644 app/src/main/java/com/screenkit/screenrecorder/adblock/AdBlockVpnService.kt create mode 100644 app/src/main/java/com/screenkit/screenrecorder/adblock/DnsMessage.kt create mode 100644 app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPacketGateway.kt create mode 100644 app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPackets.kt create mode 100644 app/src/main/java/com/screenkit/screenrecorder/ui/AdBlockCard.kt create mode 100644 app/src/test/java/com/screenkit/screenrecorder/adblock/DnsFilterTest.kt create mode 100644 docs/android-dns-filter.md diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 9103d82..322ac32 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -99,8 +99,8 @@ jobs: - name: Publish APK to GitHub Release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.1.2' }} - name: ScreenKit ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.1.2' }} + tag_name: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.2.0' }} + name: ScreenKit ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || 'v2.2.0' }} target_commitish: ${{ github.sha }} make_latest: true generate_release_notes: true diff --git a/README.md b/README.md index 1f55a17..78042af 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ScreenKit (v2.1.2) +# ScreenKit (v2.2.0) A native, high-performance Android capture, creator studio & screen automation suite built with Modern Android Architecture (Jetpack Compose, CameraX, Foreground Services, Accessibility Gesture Engine, MediaProjection, OpenGL ES, MediaMuxer/MediaExtractor, and MediaCodec). ScreenKit v2.1.0 adds **Go Live screen streaming to YouTube, Facebook, and custom RTMP/RTMPS destinations** alongside screen recording with **Pause & Resume**, custom area cropping, **Scrolling Long Screenshots ("Stitch Full Page")**, built-in **MP4 Video Trimmer, Animated GIF Maker & PNG Frame Extractor**, sequential multi-target **Auto Clicking (`1 → 2 → 3 → 4…`)**, directional **Auto Scrolling (`↑ / ↓ / ← / →`)**, **Touch Macro Recorder & Replay**, **Saved Auto-Clicker Profiles (Slots 1–3)**, **Anti-Detection Human Jitter**, **Smart Color / Pixel Trigger (`#HEX`)**, **Floating Teleprompter**, **On-Screen Magnifier Loupe & Color Eyedropper**, **Creator Watermark Badge**, **Shake / Volume-Down Emergency Stop**, **Live Usage Analytics & One-Tap In-App Updater**, live interactive brush annotation, on-screen touch visualization, a draggable floating camera lens, amplified sound recording (Mic / Device Audio / Mixed), and a built-in Black-Screen Bypass Web Capture Browser. @@ -14,9 +14,9 @@ Android-only automation/overlay and protected-capture features are not ported. ## Download and Install -[⬇️ **Download the latest ScreenKit v2.1.2 APK**](https://github.com/sharthak18/ScreenKit/releases/latest/download/ScreenKit.apk) +[⬇️ **Download the latest ScreenKit v2.2.0 APK**](https://github.com/sharthak18/ScreenKit/releases/latest/download/ScreenKit.apk) -- **Current Version**: `v2.1.2` (`versionCode = 10`) +- **Current Version**: `v2.2.0` (`versionCode = 11`) - Compatible with **Android 7.0 (API 24) to Android 15 (API 35)**. - Device Audio & Mixed (`Mic + Device`) playback capture supported on **Android 10+**. - Full screen scope and single-app exclusion selection supported on **Android 14+**. @@ -24,6 +24,39 @@ Android-only automation/overlay and protected-capture features are not ported. --- +## New in v2.2.0 — optional network ad filtering + +Open **Network ad filtering → Set up filtering** in ScreenKit. Read the disclosure, +allow the Android VPN request, and leave floating controls running. After this opt-in, +filtering automatically starts with the floating service (even over other apps), and +closing the bubble stops it. **Turn off** in the card or notification also disables +future automatic starts. This is **not** Android's Always-on VPN/lockdown mode. + +- A DNS-only, local split VPN routes **only its virtual DNS address**. Normal web/video + traffic stays on the underlying network; no general traffic tunnel, root, installed + certificate, HTTP interception, or TLS decryption of other apps. +- A small bundled starter list blocks known advertising domains and their subdomains. + It is deliberately **not comprehensive** and does not fetch remote lists or updates. + Allowed questions go to **Cloudflare DNS over TLS** (port 853, system certificate and + hostname validation). Cloudflare receives those domains and the device's public IP; + [its resolver privacy policy](https://developers.cloudflare.com/1.1.1.1/privacy/public-dns-resolver/) + applies. ScreenKit logs/saves no names or DNS history. Counts are in-memory per session. +- Only one VPN can run. ScreenKit won't automatically replace another VPN. Strict Android + Private DNS must be turned off first; apps using custom DNS/DoH can bypass the filter. + IPv6 application traffic is left alone; ordinary AAAA queries through the virtual DNS + resolver can still be filtered. IPv6-only upstream uses Cloudflare's IPv6 address. +- It **cannot promise removal of YouTube/Facebook/Instagram video ads**, same-host ads, + cached/preloaded ads or apps' own encrypted DNS. Domain filtering can break a site/app; + use Turn off to restore normal DNS. "Queries blocked" does not mean "ads removed". +- Repeated upstream failures close the VPN and restore normal networking (**fail open**). + No automatic restart loop. Open the card to retry. Android permission revocation turns + the preference off. No boot receiver or Android Always-on/lockdown support. +- Adds `ACCESS_NETWORK_STATE` and the system-protected VPN service; consent is never + requested silently. Notification shows activity and a Turn off action (if notifications + are allowed). Build/tests do not substitute for real-device network testing. + +See [architecture, limitations and device tests](docs/android-dns-filter.md). + ## Updated in v2.1.2 - Removed **Zoom** from the floating menu; the underlying magnifier is not deleted. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 61198ad..f61a894 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.screenkit.screenrecorder" minSdk = 24 targetSdk = 34 - versionCode = 10 - versionName = "2.1.2" + versionCode = 11 + versionName = "2.2.0" } compileOptions { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f37f28f..c9221ac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + @@ -61,6 +62,26 @@ android:taskAffinity="" android:theme="@style/Theme.ScreenKit.Transparent" /> + + + + + + + + + + + if (result.resultCode == Activity.RESULT_OK && VpnService.prepare(this) == null) enable() + else { AdBlockState.set("Not filtering — VPN consent was declined"); finish() } + } + private val notifications = registerForActivityResult(ActivityResultContracts.RequestPermission()) { requestVpn() } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + awaitingSystem = savedInstanceState?.getBoolean("awaitingSystem") ?: false + if (awaitingSystem) return // ActivityResultRegistry restores outstanding system consent. + showDisclosure() + } + private fun showDisclosure() { + AlertDialog.Builder(this).setTitle("Network ad filtering (experimental)") + .setMessage("ScreenKit uses Android's VPN permission to filter this device's ordinary DNS requests while its floating controls run—even when another app is on screen. Only DNS to a virtual resolver enters the local tunnel; other traffic goes directly to the network.\n\n" + + "A small bundled list blocks known ad domains. Allowed queries go encrypted to Cloudflare DNS (cloudflare-dns.com), which receives the requested domains and your public IP under its own privacy policy. ScreenKit saves no DNS history and installs no certificate.\n\n" + + "Does not reliably block YouTube/Facebook/Instagram video ads, cached ads, or apps using their own encrypted DNS. A block can break a site; Turn off restores normal DNS. Another VPN cannot run alongside this. If another VPN is active, stop it first. Strict Android Private DNS must be off. Do not enable Android's Always-on VPN or Block connections without VPN for this split DNS tool.\n\n" + + "After this one-time setup, filtering starts with the floating controls and stops when you close them. A visible notification has a Turn off action. Resolver failure stops filtering and restores normal networking rather than blocking your connection.") + .setPositiveButton("Agree & enable") { _, _ -> + if (!Settings.canDrawOverlays(this)) { + AdBlockState.set("Enable ScreenKit floating controls first, then enable Ad filtering") + finish() + } else if (Build.VERSION.SDK_INT >= 33) { awaitingSystem = true; notifications.launch(Manifest.permission.POST_NOTIFICATIONS) } + else requestVpn() + } + .setNeutralButton("Resolver privacy") { _, _ -> + runCatching { startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://developers.cloudflare.com/1.1.1.1/privacy/public-dns-resolver/"))) } + finish() + } + .setNegativeButton("Cancel") { _, _ -> finish() } + .setOnCancelListener { finish() }.show() + } + private fun requestVpn() { + // Don't launch prepare() over another VPN. Users must explicitly stop it in Android first. + val cm = getSystemService(android.net.ConnectivityManager::class.java) + if (!AdBlockState.status.value.active && cm.allNetworks.any { + cm.getNetworkCapabilities(it)?.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) == true + }) { + AdBlockState.set("Stop your other VPN in Android settings, then enable Ad filtering") + finish(); return + } + val consent = VpnService.prepare(this) + if (consent == null) enable() else { awaitingSystem = true; vpnConsent.launch(consent) } + } + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean("awaitingSystem", awaitingSystem) + super.onSaveInstanceState(outState) + } + private fun enable() { + AdBlockState.setEnabled(this, true) + FloatingOverlayService.show(this) + // The overlay's start hook also calls this, handling the case where it isn't running yet. + AdBlockVpnService.startIfEnabled(this) + finish() + } +} diff --git a/app/src/main/java/com/screenkit/screenrecorder/adblock/AdBlockState.kt b/app/src/main/java/com/screenkit/screenrecorder/adblock/AdBlockState.kt new file mode 100644 index 0000000..6445f73 --- /dev/null +++ b/app/src/main/java/com/screenkit/screenrecorder/adblock/AdBlockState.kt @@ -0,0 +1,33 @@ +package com.screenkit.screenrecorder.adblock + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +internal data class AdBlockStatus( + val enabled: Boolean = false, + val active: Boolean = false, + val message: String = "Off — network filtering is optional", + val queries: Long = 0, + val blocked: Long = 0, +) +internal object AdBlockState { + private val mutable = MutableStateFlow(AdBlockStatus()) + val status = mutable.asStateFlow() + fun enabled(context: Context) = context.getSharedPreferences("ad_filter", Context.MODE_PRIVATE).getBoolean("enabled", false) + fun refresh(context: Context) { + val enabled = enabled(context) + mutable.update { it.copy(enabled = enabled, message = if (enabled && !it.enabled) "Ready — starts with floating controls" else it.message) } + } + fun setEnabled(context: Context, enabled: Boolean) { + context.getSharedPreferences("ad_filter", Context.MODE_PRIVATE).edit().putBoolean("enabled", enabled).apply() + mutable.update { it.copy(enabled = enabled, active = if (enabled) it.active else false, + message = if (enabled) "Ready — starts with floating controls" else "Off — network filtering is optional") } + } + fun set(message: String, active: Boolean = false, resetCounts: Boolean = false) { + mutable.update { it.copy(message = message, active = active, + queries = if (resetCounts) 0 else it.queries, blocked = if (resetCounts) 0 else it.blocked) } + } + fun counted(blocked: Boolean) { mutable.update { it.copy(queries = it.queries + 1, blocked = it.blocked + if (blocked) 1 else 0) } } +} diff --git a/app/src/main/java/com/screenkit/screenrecorder/adblock/AdBlockVpnService.kt b/app/src/main/java/com/screenkit/screenrecorder/adblock/AdBlockVpnService.kt new file mode 100644 index 0000000..53938c3 --- /dev/null +++ b/app/src/main/java/com/screenkit/screenrecorder/adblock/AdBlockVpnService.kt @@ -0,0 +1,239 @@ +package com.screenkit.screenrecorder.adblock + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.VpnService +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.ParcelFileDescriptor +import android.os.SystemClock +import android.system.OsConstants +import androidx.core.app.NotificationCompat +import com.screenkit.screenrecorder.MainActivity +import com.screenkit.screenrecorder.R +import com.screenkit.screenrecorder.services.FloatingOverlayService +import java.io.DataInputStream +import java.io.DataOutputStream +import java.net.Inet4Address +import java.net.InetSocketAddress +import java.net.Socket +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import javax.net.ssl.SNIHostName +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory + +/** Split DNS VPN: only 192.0.2.2/32 enters TUN, never app video/HTTPS traffic. */ +class AdBlockVpnService : VpnService() { + private val main = Handler(Looper.getMainLooper()) + private val closed = AtomicBoolean(false) + private val failures = AtomicInteger(0) + private var started = false + private var tunnel: ParcelFileDescriptor? = null + private var gateway: DnsPacketGateway? = null + private var reader: Thread? = null + private val sockets = ConcurrentHashMap.newKeySet() + private val workers = ThreadPoolExecutor(4, 4, 0, TimeUnit.MILLISECONDS, ArrayBlockingQueue(32)) + private lateinit var blocklist: DomainBlocklist + private val ticker = object : Runnable { + override fun run() { + if (closed.get()) return + if (!FloatingOverlayService.isRunning) { stopSelf(); return } + if (strictPrivateDns()) { fail("Stopped: turn off strict Private DNS before enabling this DNS filter"); return } + gateway?.tick() + runCatching { getSystemService(NotificationManager::class.java).notify(NOTIFICATION, notification()) } + main.postDelayed(this, 1000) + } + } + companion object { + private const val CHANNEL = "dns_filter" + private const val NOTIFICATION = 5401 + private const val DISABLE = "screenkit.adfilter.DISABLE" + @Volatile private var running = false + fun startIfEnabled(context: Context) { + AdBlockState.refresh(context) + if (!AdBlockState.enabled(context) || running || !FloatingOverlayService.isRunning) return + if (prepare(context) != null) { AdBlockState.set("VPN permission needed — open Ad filtering to enable"); return } + val manager = context.getSystemService(ConnectivityManager::class.java) + if (manager.allNetworks.any { manager.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true }) { + AdBlockState.set("Not filtering: another VPN is active. Stop it, then retry.") + return + } + runCatching { + val intent = Intent(context, AdBlockVpnService::class.java) + if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) + }.onFailure { AdBlockState.set("Could not start filtering — open ScreenKit and retry") } + } + fun disable(context: Context) { + AdBlockState.setEnabled(context, false) + context.stopService(Intent(context, AdBlockVpnService::class.java)) + } + fun stopWithOverlay(context: Context) { + context.stopService(Intent(context, AdBlockVpnService::class.java)) + if (AdBlockState.enabled(context)) AdBlockState.set("Ready — starts with floating controls") + } + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == DISABLE) { disable(this); stopSelf(); return START_NOT_STICKY } + if (started) return START_NOT_STICKY + started = true + if (!AdBlockState.enabled(this) || !FloatingOverlayService.isRunning) { stopSelf(); return START_NOT_STICKY } + try { + if (Build.VERSION.SDK_INT >= 26) getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel(CHANNEL, "Network ad filtering", NotificationManager.IMPORTANCE_LOW)) + AdBlockState.set("Starting DNS filter…", resetCounts = true) + if (Build.VERSION.SDK_INT >= 34) startForeground(NOTIFICATION, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + else startForeground(NOTIFICATION, notification()) + if (strictPrivateDns()) { fail("Not filtering: turn off strict Private DNS in Android settings first"); return START_NOT_STICKY } + if (prepare(this) != null) { fail("VPN permission needed — enable Ad filtering again"); return START_NOT_STICKY } + // Never take over an existing VPN automatically, even if consent changed during startup. + val cm = getSystemService(ConnectivityManager::class.java) + if (cm.allNetworks.any { cm.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true }) { + fail("Not filtering: another VPN is active"); return START_NOT_STICKY + } + blocklist = assets.open("ad-domains.txt").bufferedReader().use { DomainBlocklist(it.readLines().asSequence()) } + val open = PendingIntent.getActivity(this, 5400, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + tunnel = Builder().setSession("ScreenKit DNS ad filter") + .setConfigureIntent(open).setMtu(1500).setBlocking(false) + .addAddress("192.0.2.1", 32).addDnsServer("192.0.2.2").addRoute("192.0.2.2", 32) + .allowFamily(OsConstants.AF_INET6) // Do not black-hole IPv6 app traffic. + .establish() ?: error("VPN permission unavailable") + val descriptor = tunnel!!.fileDescriptor + gateway = DnsPacketGateway(::writePacket, ::resolve, SystemClock::elapsedRealtime) + running = true + AdBlockState.set("Filtering DNS · ${blocklist.size} starter domains · Cloudflare encrypted resolver", active = true) + reader = Thread({ + val buffer = ByteArray(32767) + try { + while (!closed.get()) { + val poll = android.system.StructPollfd().apply { + fd = descriptor; events = OsConstants.POLLIN.toShort() + } + if (android.system.Os.poll(arrayOf(poll), 250) == 0) continue + if (closed.get()) break + val count = try { android.system.Os.read(descriptor, buffer, 0, buffer.size) } + catch (error: android.system.ErrnoException) { + if (error.errno == OsConstants.EAGAIN || error.errno == OsConstants.EINTR) continue + throw error + } + if (count <= 0) break + gateway?.accept(buffer.copyOf(count)) + } + if (!closed.get()) fail("Filtering stopped: VPN connection closed. Retry in ScreenKit.") + } catch (_: Exception) { + if (!closed.get()) fail("Filtering stopped: VPN connection interrupted. Retry in ScreenKit.") + } + }, "ScreenKitDnsPackets").also { it.start() } + main.post(ticker) + } catch (_: Exception) { fail("Could not start DNS filtering. Normal networking is restored; retry in ScreenKit.") } + return START_NOT_STICKY + } + private fun strictPrivateDns(): Boolean { + if (Build.VERSION.SDK_INT < 28) return false + val cm = getSystemService(ConnectivityManager::class.java) + return cm.allNetworks.any { network -> cm.getLinkProperties(network)?.privateDnsServerName?.isNotBlank() == true } + } + private fun writePacket(bytes: ByteArray) { + if (closed.get()) return + try { tunnel?.fileDescriptor?.let { android.system.Os.write(it, bytes, 0, bytes.size) } } + catch (error: android.system.ErrnoException) { + // Backpressure drops a packet; UDP clients retry, TCP retransmission is bounded by the gateway. + if (error.errno != OsConstants.EAGAIN && error.errno != OsConstants.EINTR && !closed.get()) + fail("Filtering stopped: DNS tunnel failed. Normal networking is restored.") + } catch (_: Exception) { + if (!closed.get()) fail("Filtering stopped: DNS tunnel failed. Normal networking is restored.") + } + } + private fun resolve(query: ByteArray, done: (ByteArray) -> Unit) { + if (closed.get()) return + val blocked = blocklist.blocks(DnsMessage.question(query)!!.name) + AdBlockState.counted(blocked) + if (blocked) { done(DnsMessage.reply(query, 3)); return } + try { + workers.execute { + if (closed.get()) return@execute + val reply = try { + val answer = encryptedLookup(query) + check(DnsMessage.validReply(query, answer)) + failures.set(0) + answer + } catch (_: Exception) { + // Fail open after repeated resolver failures; no persistent dead DNS tunnel. + if (!closed.get() && failures.incrementAndGet() >= 3) fail("Filtering stopped: encrypted DNS is unreachable. Normal networking is restored; retry when connected.") + DnsMessage.reply(query, 2) + } + if (!closed.get()) done(reply) + } + } catch (_: java.util.concurrent.RejectedExecutionException) { done(DnsMessage.reply(query, 2)) } + } + private fun encryptedLookup(query: ByteArray): ByteArray { + val cm = getSystemService(ConnectivityManager::class.java) + val ipv4 = cm.allNetworks.any { n -> + cm.getNetworkCapabilities(n)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) != true && + cm.getLinkProperties(n)?.linkAddresses?.any { it.address is Inet4Address } == true + } + // Literal addresses avoid circular DNS resolution through this tunnel. No plaintext fallback. + val address = if (ipv4) "1.1.1.1" else "2606:4700:4700::1111" + val raw = Socket() + sockets.add(raw) + val deadline = Runnable { runCatching { raw.close() } } + main.postDelayed(deadline, 8000) // Absolute lookup bound, including handshake and a slow response. + try { + check(!closed.get() && protect(raw)) + raw.soTimeout = 3000 + raw.connect(InetSocketAddress(address, 853), 2500) + val ssl = (SSLSocketFactory.getDefault() as SSLSocketFactory) + .createSocket(raw, "cloudflare-dns.com", 853, true) as SSLSocket + ssl.use { + it.soTimeout = 3000 + it.sslParameters = it.sslParameters.apply { + endpointIdentificationAlgorithm = "HTTPS" + serverNames = listOf(SNIHostName("cloudflare-dns.com")) + } + it.startHandshake() // System trust + hostname verification, never a custom trust-all verifier. + val out = DataOutputStream(it.outputStream) + out.writeShort(query.size); out.write(query); out.flush() + val input = DataInputStream(it.inputStream) + val size = input.readUnsignedShort() + require(size in 12..65535) + return ByteArray(size).also(input::readFully) + } + } finally { main.removeCallbacks(deadline); sockets.remove(raw); runCatching { raw.close() } } + } + private fun fail(message: String) { main.post { if (!closed.get()) { AdBlockState.set(message); stopSelf() } } } + private fun notification() = NotificationCompat.Builder(this, CHANNEL) + .setSmallIcon(R.drawable.ic_stat_capture).setContentTitle("ScreenKit DNS ad filtering") + .setContentText("${AdBlockState.status.value.blocked} DNS queries blocked · tap to manage") + .setContentIntent(PendingIntent.getActivity(this, 5400, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)) + .setOngoing(true).setOnlyAlertOnce(true).setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .addAction(0, "Turn off", PendingIntent.getService(this, 5401, Intent(this, javaClass).setAction(DISABLE), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)) + .build() + override fun onRevoke() { + AdBlockState.setEnabled(this, false) + AdBlockState.set("VPN permission revoked or another VPN started — filtering is off") + stopSelf() + } + override fun onDestroy() { + closed.set(true); running = false + main.removeCallbacksAndMessages(null) + gateway?.close(); gateway = null + sockets.forEach { runCatching { it.close() } }; sockets.clear() + workers.shutdownNow() + runCatching { tunnel?.close() }; tunnel = null + reader?.interrupt(); reader = null + if (AdBlockState.status.value.active) AdBlockState.set("Ready — starts with floating controls") + stopForeground(STOP_FOREGROUND_REMOVE) + super.onDestroy() + } +} diff --git a/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsMessage.kt b/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsMessage.kt new file mode 100644 index 0000000..cb4d0c1 --- /dev/null +++ b/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsMessage.kt @@ -0,0 +1,76 @@ +package com.screenkit.screenrecorder.adblock + +import java.util.Locale + +/** Strict, bounded DNS question parsing. No packet contents or names are logged/persisted. */ +internal object DnsMessage { + data class Question(val name: String, val type: Int, val clazz: Int, val end: Int, val udpLimit: Int) + fun u16(b: ByteArray, at: Int) = ((b[at].toInt() and 255) shl 8) or (b[at + 1].toInt() and 255) + fun put16(b: ByteArray, at: Int, n: Int) { b[at] = (n ushr 8).toByte(); b[at + 1] = n.toByte() } + fun question(b: ByteArray): Question? = runCatching { + require(b.size in 17..65535 && u16(b, 4) == 1 && (b[2].toInt() and 0x78) == 0) + var at = 12 + val labels = mutableListOf() + var nameLength = 0 + while (true) { + val length = b[at++].toInt() and 255 + if (length == 0) break + // Compressed questions aren't emitted by ordinary stub resolvers. Reject rather than follow pointers. + require(length in 1..63 && at + length < b.size) + val label = b.copyOfRange(at, at + length) + require(label.all { (it.toInt() and 255) in 33..126 && it != '.'.code.toByte() }) + labels += String(label, Charsets.US_ASCII) + nameLength += length + 1 + require(nameLength <= 253) + at += length + } + require(at + 4 <= b.size) + val type = u16(b, at); val clazz = u16(b, at + 2); val end = at + 4 + var udpLimit = 512 + // Only inspect the common single OPT record on a request; never trust lengths from the wire. + if ((b[2].toInt() and 0x80) == 0 && u16(b, 6) == 0 && u16(b, 8) == 0 && u16(b, 10) == 1 && + end + 11 <= b.size && b[end] == 0.toByte() && u16(b, end + 1) == 41) { + require(end + 11 + u16(b, end + 9) == b.size) + udpLimit = u16(b, end + 3).coerceIn(512, 1232) + } + Question(labels.joinToString(".").lowercase(Locale.ROOT), type, clazz, end, udpLimit) + }.getOrNull() + + fun isQuery(b: ByteArray) = b.size in 17..4096 && (b[2].toInt() and 0x80) == 0 && question(b) != null + fun reply(query: ByteArray, rcode: Int, truncated: Boolean = false): ByteArray { + val end = question(query)?.end ?: 12.coerceAtMost(query.size) + val result = query.copyOf(end) + if (result.size < 12) return ByteArray(0) + // QR, echoed RD/CD, RA. No AD bit: local blocked answers are not DNSSEC-authenticated. + result[2] = (0x80 or (query[2].toInt() and 1) or if (truncated) 2 else 0).toByte() + result[3] = (0x80 or (query[3].toInt() and 0x10) or rcode).toByte() + for (at in 6..11) result[at] = 0 + return result + } + fun validReply(query: ByteArray, reply: ByteArray): Boolean { + val q = question(query) ?: return false + val r = question(reply) ?: return false + return reply[0] == query[0] && reply[1] == query[1] && (reply[2].toInt() and 0x80) != 0 && + q.name == r.name && q.type == r.type && q.clazz == r.clazz + } +} + +internal class DomainBlocklist(lines: Sequence) { + private val domains = lines.map { it.substringBefore('#').trim().trimEnd('.').lowercase(Locale.ROOT) } + .filter { it.isNotEmpty() }.onEach { domain -> + require(domain.length <= 253 && domain.contains('.') && domain.split('.').all { label -> + label.length in 1..63 && label.all { it in 'a'..'z' || it in '0'..'9' || it == '-' } + }) + }.toSet() + val size get() = domains.size + fun blocks(name: String): Boolean { + var suffix = name.trimEnd('.').lowercase(Locale.ROOT) + while (suffix.isNotEmpty()) { + if (suffix in domains) return true + val dot = suffix.indexOf('.') + if (dot < 0) break + suffix = suffix.substring(dot + 1) + } + return false + } +} diff --git a/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPacketGateway.kt b/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPacketGateway.kt new file mode 100644 index 0000000..3119ac8 --- /dev/null +++ b/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPacketGateway.kt @@ -0,0 +1,155 @@ +package com.screenkit.screenrecorder.adblock + +import java.security.SecureRandom +import java.util.ArrayDeque + +/** Small bounded DNS-only endpoint, NOT a general TCP/IP proxy. No default VPN route exists. + * Supports UDP and length-framed TCP DNS (segmentation, ACKs, retransmission, FIN and flow control). + * All mutable transport state is serialized. Resolver callbacks may come from bounded worker threads. + */ +internal class DnsPacketGateway( + private val emit: (ByteArray) -> Unit, + private val resolve: (ByteArray, (ByteArray) -> Unit) -> Unit, + private val clock: () -> Long, +) { + private data class Key(val source: List, val port: Int) + private data class Sent(val seq: Long, val flags: Int, val bytes: ByteArray, var at: Long, var retries: Int = 0) { + val end get() = plus(seq, bytes.size + if (flags and 3 != 0) 1 else 0) + } + private class Connection(val packet: DnsPackets.Packet, val clientStart: Long, val serverStart: Long, now: Long) { + var receiveNext = plus(clientStart, 1) + var sendNext = serverStart + var acked = serverStart + var window = packet.window + var established = false + var input = byteArrayOf() + var output = byteArrayOf() + var pending = 0 + var peerFin = false + var finSent = false + var touched = now + val sent = ArrayDeque() + } + private val connections = LinkedHashMap() + private val random = SecureRandom() + private var closed = false + private var udpPending = 0 + private companion object { + fun plus(n: Long, amount: Int) = (n + amount) and 0xffffffffL + fun distance(end: Long, start: Long) = (end - start) and 0xffffffffL + } + @Synchronized fun close() { closed = true; connections.clear() } + @Synchronized fun accept(bytes: ByteArray) { + if (closed) return + val p = DnsPackets.parse(bytes) ?: return + if (p.protocol == 17) { + if (p.destinationPort != 53 || !DnsMessage.isQuery(p.payload)) return + if (udpPending >= 32) { emit(DnsPackets.udp(p, DnsMessage.reply(p.payload, 2))); return } + udpPending++ + resolve(p.payload) { response -> synchronized(this) { + udpPending-- + if (!closed) { + val max = DnsMessage.question(p.payload)!!.udpLimit + emit(DnsPackets.udp(p, if (response.size > max) DnsMessage.reply(p.payload, 0, true) else response)) + } + } } + return + } + if (p.flags and 4 != 0) { connections.remove(Key(p.source.toList(), p.sourcePort)); return } + // Reject Private DNS probing at this synthetic endpoint; it is not a TLS interception server. + if (p.destinationPort != 53) { reset(p); return } + val key = Key(p.source.toList(), p.sourcePort) + val c = connections[key] + if (p.flags and 2 != 0) { + if (p.flags and 16 != 0 || p.payload.isNotEmpty()) { reset(p); return } + if (c != null && c.clientStart == p.seq) { c.sent.firstOrNull()?.let { retransmit(c, it) }; return } + if (connections.size >= 32 && c == null) { reset(p); return } + val created = Connection(p, p.seq, random.nextInt().toLong() and 0xffffffffL, clock()) + connections[key] = created + send(created, 0x12, byteArrayOf()) + return + } + if (c == null) { reset(p); return } + if (p.flags and 16 == 0) return + val advanced = distance(p.ack, c.acked) + if (advanced > distance(c.sendNext, c.acked)) return + c.acked = p.ack; c.window = p.window; c.touched = clock() + while (c.sent.isNotEmpty() && distance(p.ack, c.sent.first.end) < 0x80000000L) c.sent.removeFirst() + if (!c.established) { + if (p.ack != plus(c.serverStart, 1)) return + c.established = true + } + if (c.finSent && c.sent.isEmpty()) { connections.remove(key); return } + if (p.seq != c.receiveNext) { acknowledge(c); flush(c); return } + if (p.payload.isNotEmpty()) { + if (c.peerFin || c.input.size + p.payload.size > 8192) { connections.remove(key); reset(p); return } + c.input += p.payload; c.receiveNext = plus(c.receiveNext, p.payload.size) + acknowledge(c) + while (c.input.size >= 2) { + val size = DnsMessage.u16(c.input, 0) + if (size !in 17..4096 || c.pending >= 8) { connections.remove(key); reset(p); return } + if (c.input.size < size + 2) break + val query = c.input.copyOfRange(2, size + 2); c.input = c.input.copyOfRange(size + 2, c.input.size) + if (!DnsMessage.isQuery(query)) { connections.remove(key); reset(p); return } + c.pending++ + val connection = c + resolve(query) { reply -> synchronized(this) { + if (!closed && connections[key] === connection) { + connection.pending-- + val data = ByteArray(2 + reply.size) + DnsMessage.put16(data, 0, reply.size); reply.copyInto(data, 2) + if (connection.output.size + distance(connection.sendNext, connection.acked) + data.size > 96 * 1024) { + connections.remove(key); reset(p) + } else { + connection.output += data + flush(connection) + } + } + } } + } + } + if (p.flags and 1 != 0 && !c.peerFin) { + c.peerFin = true; c.receiveNext = plus(c.receiveNext, 1); acknowledge(c) + if (c.input.isNotEmpty()) { connections.remove(key); reset(p); return } + } + flush(c) + } + private fun reset(p: DnsPackets.Packet) { + if (p.flags and 16 != 0) emit(DnsPackets.tcp(p, p.ack, 0, 4)) + else emit(DnsPackets.tcp(p, 0, plus(p.seq, p.payload.size + if (p.flags and 3 != 0) 1 else 0), 0x14)) + } + private fun acknowledge(c: Connection) { emit(DnsPackets.tcp(c.packet, c.sendNext, c.receiveNext, 16)) } + private fun send(c: Connection, flags: Int, payload: ByteArray) { + val sent = Sent(c.sendNext, flags, payload, clock()) + c.sent.addLast(sent); c.sendNext = sent.end + retransmit(c, sent) + } + private fun retransmit(c: Connection, sent: Sent) { emit(DnsPackets.tcp(c.packet, sent.seq, c.receiveNext, sent.flags, sent.bytes)) } + private fun flush(c: Connection) { + if (!c.established) return + while (c.output.isNotEmpty()) { + val available = c.window - distance(c.sendNext, c.acked).toInt() + if (available <= 0) return + val count = minOf(1200, available, c.output.size) + val bytes = c.output.copyOfRange(0, count); c.output = c.output.copyOfRange(count, c.output.size) + send(c, 0x18, bytes) + } + if (c.peerFin && c.pending == 0 && !c.finSent && c.window > distance(c.sendNext, c.acked)) { + c.finSent = true; send(c, 0x11, byteArrayOf()) + } + } + @Synchronized fun tick() { + if (closed) return + val now = clock() + val it = connections.entries.iterator() + while (it.hasNext()) { + val c = it.next().value + if (now - c.touched > 30_000) { it.remove(); continue } + val first = c.sent.firstOrNull() ?: continue + if (now - first.at >= (1000L shl first.retries.coerceAtMost(3))) { + if (first.retries++ >= 4) { it.remove(); continue } + first.at = now; retransmit(c, first) + } + } + } +} diff --git a/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPackets.kt b/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPackets.kt new file mode 100644 index 0000000..7d491ab --- /dev/null +++ b/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPackets.kt @@ -0,0 +1,77 @@ +package com.screenkit.screenrecorder.adblock + +/** IPv4 only: the VPN routes just its synthetic IPv4 DNS address; IPv6 data traffic bypasses it. */ +internal object DnsPackets { + val DNS_ADDRESS = byteArrayOf(192.toByte(), 0, 2, 2) + data class Packet(val source: ByteArray, val destination: ByteArray, val sourcePort: Int, + val destinationPort: Int, val protocol: Int, val seq: Long, val ack: Long, + val flags: Int, val window: Int, val payload: ByteArray) + fun parse(bytes: ByteArray): Packet? = runCatching { + require(bytes.size >= 28 && (bytes[0].toInt() ushr 4 and 15) == 4) + val header = (bytes[0].toInt() and 15) * 4 + val length = DnsMessage.u16(bytes, 2) + require(header >= 20 && length in (header + 8)..bytes.size) + require(DnsMessage.u16(bytes, 6) and 0x3fff == 0) // No fragments. + require(checksum(bytes.copyOfRange(0, header)) == 0) + val proto = bytes[9].toInt() and 255 + require(proto == 6 || proto == 17) + val source = bytes.copyOfRange(12, 16); val dest = bytes.copyOfRange(16, 20) + require(dest.contentEquals(DNS_ADDRESS)) + val segment = bytes.copyOfRange(header, length) + if (proto == 17) { + require(DnsMessage.u16(segment, 4) == segment.size) + require(DnsMessage.u16(segment, 6) == 0 || transportChecksum(source, dest, proto, segment) == 0) + Packet(source, dest, DnsMessage.u16(segment, 0), DnsMessage.u16(segment, 2), proto, + 0, 0, 0, 0, segment.copyOfRange(8, segment.size)) + } else { + require(segment.size >= 20) + val offset = (segment[12].toInt() ushr 4 and 15) * 4 + require(offset in 20..segment.size && transportChecksum(source, dest, proto, segment) == 0) + Packet(source, dest, DnsMessage.u16(segment, 0), DnsMessage.u16(segment, 2), proto, + u32(segment, 4), u32(segment, 8), segment[13].toInt() and 255, + DnsMessage.u16(segment, 14), segment.copyOfRange(offset, segment.size)) + } + }.getOrNull() + fun udp(p: Packet, payload: ByteArray): ByteArray { + val segment = ByteArray(8 + payload.size) + DnsMessage.put16(segment, 0, p.destinationPort); DnsMessage.put16(segment, 2, p.sourcePort) + DnsMessage.put16(segment, 4, segment.size); payload.copyInto(segment, 8) + DnsMessage.put16(segment, 6, transportChecksum(p.destination, p.source, 17, segment).let { if (it == 0) 65535 else it }) + return ip(p.destination, p.source, 17, segment) + } + fun tcp(p: Packet, seq: Long, ack: Long, flags: Int, payload: ByteArray = byteArrayOf()): ByteArray { + // Advertise MSS 1200 in SYN-ACK. No window scaling/SACK/timestamps negotiated. + val header = if (flags and 2 != 0) 24 else 20 + val segment = ByteArray(header + payload.size) + DnsMessage.put16(segment, 0, p.destinationPort); DnsMessage.put16(segment, 2, p.sourcePort) + put32(segment, 4, seq); put32(segment, 8, ack) + segment[12] = ((header / 4) shl 4).toByte(); segment[13] = flags.toByte() + DnsMessage.put16(segment, 14, 8192) + if (header == 24) { segment[20] = 2; segment[21] = 4; DnsMessage.put16(segment, 22, 1200) } + payload.copyInto(segment, header) + DnsMessage.put16(segment, 16, transportChecksum(p.destination, p.source, 6, segment)) + return ip(p.destination, p.source, 6, segment) + } + fun ip(source: ByteArray, dest: ByteArray, proto: Int, segment: ByteArray): ByteArray { + val b = ByteArray(20 + segment.size) + b[0] = 0x45; DnsMessage.put16(b, 2, b.size); b[8] = 64; b[9] = proto.toByte() + source.copyInto(b, 12); dest.copyInto(b, 16) + DnsMessage.put16(b, 10, checksum(b.copyOfRange(0, 20))) + segment.copyInto(b, 20) + return b + } + private fun transportChecksum(source: ByteArray, dest: ByteArray, proto: Int, segment: ByteArray): Int { + val pseudo = ByteArray(12 + segment.size) + source.copyInto(pseudo); dest.copyInto(pseudo, 4); pseudo[9] = proto.toByte() + DnsMessage.put16(pseudo, 10, segment.size); segment.copyInto(pseudo, 12) + return checksum(pseudo) + } + fun checksum(b: ByteArray): Int { + var sum = 0L + for (i in b.indices step 2) sum += ((b[i].toInt() and 255) shl 8) + if (i + 1 < b.size) b[i + 1].toInt() and 255 else 0 + while (sum ushr 16 != 0L) sum = (sum and 65535) + (sum ushr 16) + return sum.toInt().inv() and 65535 + } + fun u32(b: ByteArray, at: Int): Long = (0..3).fold(0L) { n, i -> (n shl 8) or (b[at + i].toLong() and 255) } + private fun put32(b: ByteArray, at: Int, n: Long) { for (i in 0..3) b[at + i] = (n ushr ((3 - i) * 8)).toByte() } +} diff --git a/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt b/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt index 115605b..d8f574c 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/services/FloatingOverlayService.kt @@ -1,6 +1,7 @@ package com.screenkit.screenrecorder.services import android.app.Notification +import com.screenkit.screenrecorder.adblock.AdBlockVpnService import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -226,6 +227,7 @@ class FloatingOverlayService : Service() { ACTION_HIDE -> { removeOverlay() isRunning = false + AdBlockVpnService.stopWithOverlay(this) SettingsStore.setOverlayEnabled(this, false) stopForeground(STOP_FOREGROUND_REMOVE) stopSelf(startId) @@ -240,6 +242,7 @@ class FloatingOverlayService : Service() { promoteToForeground() isRunning = true SettingsStore.setOverlayEnabled(this, true) + AdBlockVpnService.startIfEnabled(this) screenRecording = ScreenCaptureService.isRecording cameraRecording = CameraCaptureService.isRecording cameraPreview = CameraCaptureService.isPreviewVisible @@ -1213,6 +1216,7 @@ class FloatingOverlayService : Service() { @Suppress("DEPRECATION") override fun onDestroy() { + AdBlockVpnService.stopWithOverlay(this) if (receiverRegistered) { runCatching { unregisterReceiver(stateReceiver) } receiverRegistered = false diff --git a/app/src/main/java/com/screenkit/screenrecorder/ui/AdBlockCard.kt b/app/src/main/java/com/screenkit/screenrecorder/ui/AdBlockCard.kt new file mode 100644 index 0000000..87c0836 --- /dev/null +++ b/app/src/main/java/com/screenkit/screenrecorder/ui/AdBlockCard.kt @@ -0,0 +1,47 @@ +package com.screenkit.screenrecorder.ui + +import android.content.Intent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.screenkit.screenrecorder.adblock.AdBlockConsentActivity +import com.screenkit.screenrecorder.adblock.AdBlockState +import com.screenkit.screenrecorder.adblock.AdBlockVpnService + +@Composable +internal fun AdBlockCard() { + val context = LocalContext.current + val status by AdBlockState.status.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { AdBlockState.refresh(context) } + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Network ad filtering", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text("Experimental · automatically runs with floating controls after you opt in.", style = MaterialTheme.typography.bodySmall) + Text(status.message, color = if (status.active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface) + Text("${status.blocked} / ${status.queries} DNS queries blocked this session — not a count of ads removed.", style = MaterialTheme.typography.bodySmall) + Text("Small on-device domain list; allowed DNS queries go encrypted to Cloudflare. No browsing history saved. Not a full VPN or guaranteed social-video ad blocker. Apps using their own DNS can bypass it.", style = MaterialTheme.typography.bodySmall) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (!status.active) Button(onClick = { + context.startActivity(Intent(context, AdBlockConsentActivity::class.java)) + }) { Text(if (status.enabled) "Start / retry" else "Set up filtering") } + if (status.enabled) OutlinedButton(onClick = { AdBlockVpnService.disable(context) }) { Text("Turn off") } + } + Text("Uses Android's VPN slot. Stop other VPNs and strict Private DNS first. Closing the bubble stops filtering; turning this off also disables automatic start.", style = MaterialTheme.typography.bodySmall) + } + } +} diff --git a/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt b/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt index 9a17611..572d461 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/ui/MainScreen.kt @@ -190,6 +190,8 @@ internal fun MainScreen( onToggleCameraRecording = onToggleCameraRecording, onProjectionScopeSelected = onProjectionScopeSelected, ) + AdBlockCard() + LiveStreamCard( busy = state.screenSessionActive || state.cameraRecording, audioMode = state.audioMode, diff --git a/app/src/test/java/com/screenkit/screenrecorder/adblock/DnsFilterTest.kt b/app/src/test/java/com/screenkit/screenrecorder/adblock/DnsFilterTest.kt new file mode 100644 index 0000000..d989b7f --- /dev/null +++ b/app/src/test/java/com/screenkit/screenrecorder/adblock/DnsFilterTest.kt @@ -0,0 +1,178 @@ +package com.screenkit.screenrecorder.adblock + +import org.junit.Assert.* +import org.junit.Test + +class DnsFilterTest { + private fun query(name: String = "example.org", type: Int = 1): ByteArray { + val bytes = mutableListOf(0x12, 0x34, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0) + for (label in name.split('.')) { bytes += label.length.toByte(); bytes += label.toByteArray(Charsets.US_ASCII).toList() } + bytes += 0; bytes += (type ushr 8).toByte(); bytes += type.toByte(); bytes += 0; bytes += 1 + return bytes.toByteArray() + } + private val client = byteArrayOf(192.toByte(), 0, 2, 1) + private fun reverse(port: Int = 53, protocol: Int = 17) = DnsPackets.Packet( + DnsPackets.DNS_ADDRESS, client, port, 40000, protocol, 0, 0, 0, 8192, byteArrayOf()) + private fun udp(q: ByteArray) = DnsPackets.udp(reverse(), q) + private fun tcp(seq: Long, ack: Long, flags: Int, payload: ByteArray = byteArrayOf(), port: Int = 53) = + DnsPackets.tcp(reverse(port, 6), seq, ack, flags, payload) + private fun frame(q: ByteArray) = ByteArray(q.size + 2).also { DnsMessage.put16(it, 0, q.size); q.copyInto(it, 2) } + private fun tcpPayload(packet: ByteArray) = packet.copyOfRange(20 + (packet[32].toInt() ushr 4 and 15) * 4, packet.size) + + @Test fun blocklistMatchesDomainBoundariesNotSubstrings() { + val list = DomainBlocklist(sequenceOf("# comment", "DoubleClick.net", "ad.example.org # note", "")) + assertEquals(2, list.size) + assertTrue(list.blocks("A.DoubleClick.NET.")) + assertTrue(list.blocks("ad.example.org")) + assertFalse(list.blocks("notdoubleclick.net")) + assertFalse(list.blocks("doubleclick.net.good.org")) + assertFalse(list.blocks("example.org")) + } + @Test fun malformedListIsRejected() { + for (bad in listOf("com", "https://ads.example.com", "ads..example.com", "*example.com")) { + try { DomainBlocklist(sequenceOf(bad)); fail(bad) } catch (_: IllegalArgumentException) { } + } + } + @Test fun parsesAaaaAndHttpsWithoutFilteringByRecordType() { + for (type in listOf(1, 28, 65, 16)) { + val q = query("Ads.Example.org", type) + assertTrue(DnsMessage.isQuery(q)) + assertEquals("ads.example.org", DnsMessage.question(q)!!.name) + assertEquals(type, DnsMessage.question(q)!!.type) + } + } + @Test fun localBlockedReplyKeepsIdAndQuestionButNotAuthenticatedBit() { + val q = query().also { it[3] = 0x30 } + val r = DnsMessage.reply(q, 3) + assertTrue(DnsMessage.validReply(q, r)) + assertEquals(3, r[3].toInt() and 15) + assertEquals(0, r[3].toInt() and 0x20) + assertEquals(0x10, r[3].toInt() and 0x10) + assertEquals(0, DnsMessage.u16(r, 6)) + assertFalse(DnsMessage.isQuery(r)) + } + @Test fun malformedAndCompressedQuestionsAreRejected() { + val q = query() + assertNull(DnsMessage.question(q.copyOf(15))) + assertNull(DnsMessage.question(q.copyOf().also { it[12] = 0xc0.toByte() })) + assertNull(DnsMessage.question(q.copyOf().also { it[5] = 2 })) + assertNull(DnsMessage.question(q.copyOf().also { it[2] = 0x79 })) + assertNull(DnsMessage.question(query("a".repeat(64) + ".org"))) + } + @Test fun replyMustMatchIdQuestionClassAndType() { + val q = query() + val answer = DnsMessage.reply(q, 0) + assertTrue(DnsMessage.validReply(q, answer)) + assertFalse(DnsMessage.validReply(q, answer.copyOf().also { it[0] = 4 })) + assertFalse(DnsMessage.validReply(q, DnsMessage.reply(query("other.org"), 0))) + assertFalse(DnsMessage.validReply(q, DnsMessage.reply(query(type = 28), 0))) + assertFalse(DnsMessage.validReply(q, q)) + } + @Test fun ednsLimitIsBoundedAndMalformedOptRejected() { + val q = query() + assertEquals(512, DnsMessage.question(q)!!.udpLimit) + val edns = (q + byteArrayOf(0, 0, 41, 0x10, 0, 0, 0, 0, 0, 0, 0)).also { it[11] = 1 } + assertEquals(1232, DnsMessage.question(edns)!!.udpLimit) + edns[edns.lastIndex] = 50 + assertNull(DnsMessage.question(edns)) + } + @Test fun ipv4UdpAndTcpChecksumsAndOptionsParse() { + val u = udp(query()) + assertArrayEquals(query(), DnsPackets.parse(u)!!.payload) + assertEquals(0, DnsPackets.checksum(u.copyOf(20))) + val t = tcp(100, 0, 2) + assertEquals(100, DnsPackets.parse(t)!!.seq) + assertEquals(2, DnsPackets.parse(t)!!.flags) + assertNull(DnsPackets.parse(u.copyOf().also { it[it.lastIndex] = 2 })) + assertNull(DnsPackets.parse(u.copyOf().also { it[10] = (it[10].toInt() xor 1).toByte() })) + } + @Test fun fragmentsOtherDestinationsAndTruncationAreRejected() { + val u = udp(query()) + val fragment = u.copyOf().also { + it[6] = 0x20; it[10] = 0; it[11] = 0 + DnsMessage.put16(it, 10, DnsPackets.checksum(it.copyOf(20))) + } + assertNull(DnsPackets.parse(fragment)) + assertNull(DnsPackets.parse(u.copyOf(24))) + assertNull(DnsPackets.parse(DnsPackets.ip(client, byteArrayOf(1, 1, 1, 1), 17, u.copyOfRange(20, u.size)))) + } + @Test fun udpRepliesAndOversizeAnswersUseTcpFallback() { + val output = mutableListOf() + var big = false + val gateway = DnsPacketGateway(output::add, { q, done -> + done(DnsMessage.reply(q, 0).let { if (big) it.copyOf(1300) else it }) + }, { 0L }) + gateway.accept(udp(query())) + assertTrue(DnsMessage.validReply(query(), output.last().copyOfRange(28, output.last().size))) + big = true; gateway.accept(udp(query())) + assertEquals(2, output.last()[30].toInt() and 2) + assertTrue(output.last().size < 512) + } + @Test fun udpQueueIsBoundedAndCloseDropsLateResults() { + val output = mutableListOf() + val callbacks = mutableListOf<(ByteArray) -> Unit>() + val gateway = DnsPacketGateway(output::add, { _, done -> callbacks += done }, { 0L }) + repeat(33) { gateway.accept(udp(query())) } + assertEquals(32, callbacks.size) + assertEquals(1, output.size) + assertEquals(2, output.last()[31].toInt() and 15) + gateway.close(); callbacks[0](DnsMessage.reply(query(), 0)) + assertEquals(1, output.size) + } + @Test fun tcpSegmentedQueryReplyFinAndDuplicateAreHandled() { + val output = mutableListOf() + var resolved = 0 + val gateway = DnsPacketGateway(output::add, { q, done -> resolved++; done(DnsMessage.reply(q, 3)) }, { 0L }) + gateway.accept(tcp(100, 0, 2)) + val server = (DnsPackets.u32(output.last(), 24) + 1) and 0xffffffffL + gateway.accept(tcp(101, server, 16)) + val q = frame(query()) + gateway.accept(tcp(101, server, 0x18, q.copyOf(3))) + assertEquals(0, resolved) + gateway.accept(tcp(104, server, 0x18, q.copyOfRange(3, q.size))) + assertEquals(1, resolved) + val reply = output.last { tcpPayload(it).isNotEmpty() } + assertTrue(DnsMessage.validReply(query(), tcpPayload(reply).drop(2).toByteArray())) + // A retransmitted query must not perform a second DNS lookup. + gateway.accept(tcp(101, server, 0x18, q)) + assertEquals(1, resolved) + val ack = (server + tcpPayload(reply).size) and 0xffffffffL + gateway.accept(tcp(101 + q.size.toLong(), ack, 0x11)) + assertEquals(0x11, output.last()[33].toInt() and 0xff) + gateway.accept(tcp(102 + q.size.toLong(), (ack + 1) and 0xffffffffL, 16)) + } + @Test fun tcpHandshakeRetransmitsAndHonorsClose() { + var now = 0L + val output = mutableListOf() + val gateway = DnsPacketGateway(output::add, { _, _ -> fail("No query") }, { now }) + gateway.accept(tcp(100, 0, 2)) + now = 1100; gateway.tick() + assertEquals(2, output.size) + assertArrayEquals(output[0], output[1]) + gateway.close(); now = 10000; gateway.tick(); gateway.accept(tcp(200, 0, 2)) + assertEquals(2, output.size) + } + @Test fun privateDnsPortGetsResetNotIntercepted() { + val output = mutableListOf() + val gateway = DnsPacketGateway(output::add, { _, _ -> fail("TLS must never be intercepted") }, { 0L }) + gateway.accept(tcp(100, 0, 2, port = 853)) + assertEquals(0x14, output.single()[33].toInt() and 0xff) + } + @Test fun tcpInvalidFrameIsReset() { + val output = mutableListOf() + val gateway = DnsPacketGateway(output::add, { _, _ -> fail("Invalid query") }, { 0L }) + gateway.accept(tcp(100, 0, 2)) + val server = (DnsPackets.u32(output.last(), 24) + 1) and 0xffffffffL + gateway.accept(tcp(101, server, 16)) + gateway.accept(tcp(101, server, 0x18, byteArrayOf(0x20, 0))) + assertEquals(4, output.last()[33].toInt() and 4) + } + @Test fun arbitraryInputNeverEscapesParserBounds() { + val random = java.util.Random(7) + val gateway = DnsPacketGateway({}, { _, _ -> }, { 0L }) + repeat(2000) { + val packet = ByteArray(random.nextInt(300)).also(random::nextBytes) + DnsMessage.question(packet); DnsPackets.parse(packet); gateway.accept(packet) + } + } +} diff --git a/docs/android-dns-filter.md b/docs/android-dns-filter.md new file mode 100644 index 0000000..a678ee0 --- /dev/null +++ b/docs/android-dns-filter.md @@ -0,0 +1,89 @@ +# ScreenKit 2.2.0 DNS filtering (experimental) + +## Scope and consent + +Explicit in-app disclosure precedes `VpnService.prepare`. Preference defaults off. After +agreement/OS approval it starts with `FloatingOverlayService`, not with boot or a hidden +background task. The bubble can stay up over other apps; ordinary device DNS is filtered +then, not just ScreenKit's DNS. Closing the bubble removes the VPN route; the remembered +preference allows the next bubble session to start it again. Turning off via card or +notification disables that preference. Other VPNs/strict Private DNS are not silently +replaced or reconfigured. Revocation disables automatic filtering until another opt-in. + +Always-on is explicitly unsupported in the manifest. Do not use Android VPN lockdown: +this split-route tool is not a privacy VPN, and lockdown conflicts with bypass traffic +and fail-open behavior. It does not conceal IP/location or encrypt general app traffic. + +## Data path + +- TUN address 192.0.2.1/32, DNS 192.0.2.2/32, **only** 192.0.2.2/32 routed. IPv6 family + explicitly allowed outside the tunnel; no default IPv4 or IPv6 route is installed. +- UDP DNS plus a bounded TCP DNS endpoint for large/TC responses. IPv4 headers and + UDP/TCP checksums validated; fragments dropped. EDNS replies capped to 1232 bytes + (512 without EDNS); larger replies set TC and rely on TCP. Ordinary stub questions + only: one question, no compressed question name, max query 4096 bytes. These are + deliberate experimental compatibility limits, not a full general-purpose IP stack. +- TCP: 32 connections, 8 pending queries per connection, 8KiB inbound window, 96KiB + queued/unacknowledged output limit, 1200-byte segments, ACKs, flow control, bounded + retransmission, FIN/RST, 30s idle eviction. No TCP options beyond advertised MSS. + The endpoint rejects port 853 probes; it never pretends to terminate app TLS. +- Bundled `app/src/main/assets/ad-domains.txt`: small hand-maintained starter list of + advertising endpoints, not an imported/subscription list. Case-insensitive exact + domain/suffix boundaries, not substring matching. Returns NXDOMAIN, no fake DNSSEC AD. + No inspection of CNAME targets in allowed replies: CNAME-only aliases can evade it. +- Allowed DNS messages use protected sockets to Cloudflare 1.1.1.1 (or + 2606:4700:4700::1111 on IPv6-only networks), TCP 853, TLS SNI/hostname cloudflare-dns.com. + System certificate trust and HTTPS endpoint identification enforced. No cleartext + fallback, no user-installed trust-all verifier, no general app TLS interception. +- Cloudflare receives unblocked query names and public IP. ScreenKit retains no query + history/names, saves only enable preference, and maintains aggregate session counters. + Small DNS/TCP buffers and outstanding requests exist in memory until reply/cleanup. +- Four resolver workers, 32 queued tasks, per-socket timeouts and 8s absolute lookup + deadline. A fresh TLS connection per lookup favors simple cleanup over efficiency; + latency/battery overhead needs device measurement. No application-level DNS cache. +- Saturation returns SERVFAIL; three consecutive transport/invalid-reply failures stop + filtering and remove the route (fail open). Ordinary networking returns, so ads may + return too. A single transient error returns SERVFAIL without ending the VPN. Explicit + retry/reopening controls can start another session; no watchdog restart loop. +- Nonblocking TUN reads via bounded poll; serialized nonblocking output, with packet loss + under backpressure handled by DNS/TCP retransmission. Closing releases descriptor, + sockets, queued work and transport state; no abandoned blocking TUN reader. + +## Automated coverage + +Pure Kotlin tests: suffix boundary/case/trailing dot, malformed lists/questions, A/AAAA/ +HTTPS questions, ID/type/question validation, NXDOMAIN header flags, EDNS bounds, IPv4/ +UDP/TCP checksums and malformed/fragments rejection, UDP truncation to TCP fallback, +queue saturation, TCP segmented query/response/duplicate/FIN, handshake retransmission, +port 853 RST, invalid length reset, shutdown callbacks, and deterministic malformed input. + +These tests do **not** exercise Android's VPN routing, platform TLS, OEM lifecycle, or a +live social app. Existing capture/automation/streaming tests remain unchanged. + +## Required physical-device matrix (NOT run in the sandbox) + +Android 7/10/13/14/15; Wi-Fi and mobile data; dual-stack and IPv6-only/NAT64: + +1. Enable/deny/cancel VPN consent and notification permission; rotate during disclosure + and OS consent. No network route before consent, no blank consent screen after rotation. +2. After opt-in: open bubble, background app, verify persistent VPN/notification and status; + close bubble, reopen, Turn off, force-stop, revoke VPN, process restart. Confirm descriptor, + worker and socket teardown and network restoration. No disabled preference auto-restart. +3. Other VPN active: no silent replacement. Starting another VPN revokes this one. Strict + Private DNS active/changed mid-session: stop with a clear message, not a black-hole DNS route. +4. Controlled test page: blocked host and subdomain return NXDOMAIN; lookalike suffix and + normal content hosts resolve. Confirm A, AAAA, HTTPS, large DNS responses/TCP fallback, + TCP segmentation/retransmission/window close/reopen, and allowed live streaming. +5. Capture underlying traffic: only DNS routed locally, upstream DNS encrypted to named + provider, no plaintext fallback; ordinary IPv4/IPv6 video/Web/Go Live unaffected. A + mismatched/untrusted/expired TLS certificate must fail, never resolve over plaintext. +6. Switch Wi-Fi/mobile; captive portal; airplane mode; block outbound 853; resolver outage; + burst queries/large replies. Failure must remove the tunnel and visibly report filtering + off. Retry after connectivity returns. Measure initial DNS latency, battery, memory. +7. Verify expected bypasses: app DoH/custom resolver, cached ads, same-host video ads, + CNAME-only alias, IPv6 custom DNS. Never display a claim that all ads were removed. +8. Site false positive: Turn off restores normal DNS (device/app caches may require retry). + Confirm zero stored DNS logs or domain-bearing diagnostics after a session. + +Do not market as production-tested before these checks. No signed store release or +Google Play VPN/foreground-service declarations have been prepared. From 643e3428a72de159fdae1f4aae7693c242ced519 Mon Sep 17 00:00:00 2001 From: sharthak18 <157966738+sharthak18@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:17:42 +0000 Subject: [PATCH 10/10] Harden DNS flow isolation and test large TCP responses Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../adblock/AdBlockVpnService.kt | 12 +++++- .../adblock/DnsPacketGateway.kt | 8 +++- .../screenrecorder/adblock/DnsPackets.kt | 4 +- .../screenrecorder/adblock/DnsFilterTest.kt | 38 ++++++++++++++++++- docs/android-dns-filter.md | 3 +- 5 files changed, 57 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/screenkit/screenrecorder/adblock/AdBlockVpnService.kt b/app/src/main/java/com/screenkit/screenrecorder/adblock/AdBlockVpnService.kt index 53938c3..49abeae 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/adblock/AdBlockVpnService.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/adblock/AdBlockVpnService.kt @@ -39,6 +39,7 @@ class AdBlockVpnService : VpnService() { private val main = Handler(Looper.getMainLooper()) private val closed = AtomicBoolean(false) private val failures = AtomicInteger(0) + private val failing = AtomicBoolean(false) private var started = false private var tunnel: ParcelFileDescriptor? = null private var gateway: DnsPacketGateway? = null @@ -211,7 +212,16 @@ class AdBlockVpnService : VpnService() { } } finally { main.removeCallbacks(deadline); sockets.remove(raw); runCatching { raw.close() } } } - private fun fail(message: String) { main.post { if (!closed.get()) { AdBlockState.set(message); stopSelf() } } } + private fun fail(message: String) { + if (!failing.compareAndSet(false, true)) return + main.post { + if (!closed.get()) { + AdBlockState.set(message) + android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show() + stopSelf() + } + } + } private fun notification() = NotificationCompat.Builder(this, CHANNEL) .setSmallIcon(R.drawable.ic_stat_capture).setContentTitle("ScreenKit DNS ad filtering") .setContentText("${AdBlockState.status.value.blocked} DNS queries blocked · tap to manage") diff --git a/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPacketGateway.kt b/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPacketGateway.kt index 3119ac8..e041647 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPacketGateway.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPacketGateway.kt @@ -55,9 +55,13 @@ internal class DnsPacketGateway( } } return } + // Port is part of a TCP flow: a TLS probe's RST must not destroy a DNS connection + // which happens to use the same client port. + if (p.destinationPort != 53) { + if (p.flags and 4 == 0) reset(p) + return + } if (p.flags and 4 != 0) { connections.remove(Key(p.source.toList(), p.sourcePort)); return } - // Reject Private DNS probing at this synthetic endpoint; it is not a TLS interception server. - if (p.destinationPort != 53) { reset(p); return } val key = Key(p.source.toList(), p.sourcePort) val c = connections[key] if (p.flags and 2 != 0) { diff --git a/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPackets.kt b/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPackets.kt index 7d491ab..79e55e9 100644 --- a/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPackets.kt +++ b/app/src/main/java/com/screenkit/screenrecorder/adblock/DnsPackets.kt @@ -39,14 +39,14 @@ internal object DnsPackets { DnsMessage.put16(segment, 6, transportChecksum(p.destination, p.source, 17, segment).let { if (it == 0) 65535 else it }) return ip(p.destination, p.source, 17, segment) } - fun tcp(p: Packet, seq: Long, ack: Long, flags: Int, payload: ByteArray = byteArrayOf()): ByteArray { + fun tcp(p: Packet, seq: Long, ack: Long, flags: Int, payload: ByteArray = byteArrayOf(), window: Int = 8192): ByteArray { // Advertise MSS 1200 in SYN-ACK. No window scaling/SACK/timestamps negotiated. val header = if (flags and 2 != 0) 24 else 20 val segment = ByteArray(header + payload.size) DnsMessage.put16(segment, 0, p.destinationPort); DnsMessage.put16(segment, 2, p.sourcePort) put32(segment, 4, seq); put32(segment, 8, ack) segment[12] = ((header / 4) shl 4).toByte(); segment[13] = flags.toByte() - DnsMessage.put16(segment, 14, 8192) + DnsMessage.put16(segment, 14, window) if (header == 24) { segment[20] = 2; segment[21] = 4; DnsMessage.put16(segment, 22, 1200) } payload.copyInto(segment, header) DnsMessage.put16(segment, 16, transportChecksum(p.destination, p.source, 6, segment)) diff --git a/app/src/test/java/com/screenkit/screenrecorder/adblock/DnsFilterTest.kt b/app/src/test/java/com/screenkit/screenrecorder/adblock/DnsFilterTest.kt index d989b7f..c115fa9 100644 --- a/app/src/test/java/com/screenkit/screenrecorder/adblock/DnsFilterTest.kt +++ b/app/src/test/java/com/screenkit/screenrecorder/adblock/DnsFilterTest.kt @@ -14,8 +14,8 @@ class DnsFilterTest { private fun reverse(port: Int = 53, protocol: Int = 17) = DnsPackets.Packet( DnsPackets.DNS_ADDRESS, client, port, 40000, protocol, 0, 0, 0, 8192, byteArrayOf()) private fun udp(q: ByteArray) = DnsPackets.udp(reverse(), q) - private fun tcp(seq: Long, ack: Long, flags: Int, payload: ByteArray = byteArrayOf(), port: Int = 53) = - DnsPackets.tcp(reverse(port, 6), seq, ack, flags, payload) + private fun tcp(seq: Long, ack: Long, flags: Int, payload: ByteArray = byteArrayOf(), port: Int = 53, window: Int = 8192) = + DnsPackets.tcp(reverse(port, 6), seq, ack, flags, payload, window) private fun frame(q: ByteArray) = ByteArray(q.size + 2).also { DnsMessage.put16(it, 0, q.size); q.copyInto(it, 2) } private fun tcpPayload(packet: ByteArray) = packet.copyOfRange(20 + (packet[32].toInt() ushr 4 and 15) * 4, packet.size) @@ -167,6 +167,40 @@ class DnsFilterTest { gateway.accept(tcp(101, server, 0x18, byteArrayOf(0x20, 0))) assertEquals(4, output.last()[33].toInt() and 4) } + @Test fun unrelatedTlsResetDoesNotDestroyDnsConnection() { + val output = mutableListOf() + var resolved = 0 + val gateway = DnsPacketGateway(output::add, { q, done -> resolved++; done(DnsMessage.reply(q, 0)) }, { 0L }) + gateway.accept(tcp(100, 0, 2)) + val server = (DnsPackets.u32(output.last(), 24) + 1) and 0xffffffffL + gateway.accept(tcp(100, 0, 4, port = 853)) + gateway.accept(tcp(101, server, 16)) + gateway.accept(tcp(101, server, 0x18, frame(query()))) + assertEquals(1, resolved) + } + @Test fun largeTcpReplyRespectsWindowAndResumesAfterAcknowledgments() { + val output = mutableListOf() + val response = DnsMessage.reply(query(), 0).copyOf(65535) + val gateway = DnsPacketGateway(output::add, { _, done -> done(response) }, { 0L }) + gateway.accept(tcp(100, 0, 2)) + val server = (DnsPackets.u32(output.last(), 24) + 1) and 0xffffffffL + gateway.accept(tcp(101, server, 16)) + val q = frame(query()) + gateway.accept(tcp(101, server, 0x18, q, window = 0)) + assertTrue(output.none { tcpPayload(it).isNotEmpty() }) + val collected = java.io.ByteArrayOutputStream() + var ack = server + repeat(9) { + output.clear() + gateway.accept(tcp(101 + q.size.toLong(), ack, 16)) + val chunk = output.flatMap { tcpPayload(it).toList() }.toByteArray() + assertTrue(chunk.size <= 8192) + collected.write(chunk) + ack = (ack + chunk.size) and 0xffffffffL + } + assertArrayEquals(frame(response), collected.toByteArray()) + } + @Test fun arbitraryInputNeverEscapesParserBounds() { val random = java.util.Random(7) val gateway = DnsPacketGateway({}, { _, _ -> }, { 0L }) diff --git a/docs/android-dns-filter.md b/docs/android-dns-filter.md index a678ee0..85a8d4f 100644 --- a/docs/android-dns-filter.md +++ b/docs/android-dns-filter.md @@ -55,7 +55,8 @@ Pure Kotlin tests: suffix boundary/case/trailing dot, malformed lists/questions, HTTPS questions, ID/type/question validation, NXDOMAIN header flags, EDNS bounds, IPv4/ UDP/TCP checksums and malformed/fragments rejection, UDP truncation to TCP fallback, queue saturation, TCP segmented query/response/duplicate/FIN, handshake retransmission, -port 853 RST, invalid length reset, shutdown callbacks, and deterministic malformed input. +port 853 RST/flow isolation, 64KiB TCP reply with zero-window/reopen and ACK pacing, +invalid length reset, shutdown callbacks, and deterministic malformed input. These tests do **not** exercise Android's VPN routing, platform TLS, OEM lifecycle, or a live social app. Existing capture/automation/streaming tests remain unchanged.