[verified] Publish ReelBox source under GPLv3

This commit is contained in:
Hermes committed 2026-11-12 23:41:13 +00:00
commit db2b505ca3
445 files changed
+105274

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
import java.util.Properties
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
val signingFile = file(System.getenv("REELBOX_SIGNING_PROPERTIES")
?: "${System.getProperty("user.home")}/.config/reelbox-signing/keystore.properties")
val signingProperties = Properties().apply {
if (signingFile.isFile) signingFile.inputStream().use { load(it) }
}
android {
signingConfigs {
create("release") {
if (signingFile.isFile) {
storeFile = file(signingProperties.getProperty("storeFile"))
storePassword = signingProperties.getProperty("storePassword")
keyAlias = signingProperties.getProperty("keyAlias")
keyPassword = signingProperties.getProperty("keyPassword")
}
}
}
namespace = "org.reelbox.reelbox"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
// Dependency release-lint analysis exceeded this host's memory/time budget.
// Run Android lint separately on a larger machine before store distribution.
lint {
checkReleaseBuilds = false
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "org.reelbox.app"
// This delivery targets ARM64. Exclude dependency-only ABIs that lack
// Flutter's libapp/libflutter and would falsely advertise support.
ndk {
abiFilters.clear()
abiFilters.add("arm64-v8a")
}
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = maxOf(flutter.minSdkVersion, 24) // Native default-network callback / scoped media lifecycle.
targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
// flag during build.
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// Missing release keys fail closed; never use Android's debug signer.
signingConfig = signingConfigs.getByName("release")
// Keep Dart AOT release mode; avoid the Java R8/resource shrinker
// on the constrained build host. This increases APK size.
isMinifyEnabled = false
isShrinkResources = false
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
dependencies {
// Maven Central metadata verified 2026-11-04: latest stable 3.7.7;
// 4.0.0-eap29 is a prerelease. API checked against published sources/classes.jar.
implementation("org.videolan.android:libvlc-all:3.7.7")
// Google currently documents 23.0.0, but its MediaRouter 1.9 beta requires
// compileSdk 37. Use verified 22.3.1 on this repository's installed SDK 35.
implementation("com.google.android.gms:play-services-cast-framework:22.3.1")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.core:core:1.16.0")
implementation("androidx.mediarouter:mediarouter:1.8.1")
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+87
View File
@@ -0,0 +1,87 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.RUN_USER_INITIATED_JOBS" />
<application
android:label="Reelbox"
android:usesCleartextTraffic="true"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name="org.reelbox.reelbox.MainActivity"
android:supportsPictureInPicture="true"
android:resizeableActivity="true"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="org.reelbox.reelbox.ReelboxCastOptions" />
<activity android:name="org.reelbox.reelbox.CastPickerActivity"
android:exported="false" android:theme="@style/ReelboxCastTheme" />
<activity android:name="org.reelbox.reelbox.EmbeddedVideoActivity"
android:exported="false" android:hardwareAccelerated="true"
android:enableOnBackInvokedCallback="true"
android:configChanges="orientation|keyboardHidden|screenSize|smallestScreenSize"
android:theme="@android:style/Theme.Material.NoActionBar" />
<activity android:name="org.reelbox.reelbox.VlcPlayerActivity"
android:exported="false" android:hardwareAccelerated="true"
android:supportsPictureInPicture="true" android:resizeableActivity="true"
android:enableOnBackInvokedCallback="true"
android:configChanges="orientation|keyboardHidden|screenSize|smallestScreenSize|screenLayout|uiMode|density"
android:theme="@android:style/Theme.Material.NoActionBar" />
<service android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync" tools:node="merge" />
<service android:name="org.reelbox.reelbox.BackgroundPlaybackService"
android:exported="false" android:stopWithTask="true"
android:foregroundServiceType="mediaPlayback" />
<provider android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files" android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/reelbox_file_paths" />
</provider>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,209 @@
package org.reelbox.reelbox
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaMetadata
import android.media.session.MediaSession
import android.media.session.PlaybackState
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import io.flutter.plugin.common.MethodChannel
/** The existing Flutter engine/Player owns decoding and is the sole event consumer. */
object NativeMediaEvents {
var channel: MethodChannel? = null
fun action(action: String, positionMs: Long? = null) {
val arguments = mutableMapOf<String, Any>("action" to action)
if (positionMs != null) arguments["positionMs"] = positionMs
Handler(Looper.getMainLooper()).post { channel?.invokeMethod("mediaAction", arguments) }
}
}
/** Foreground lifecycle/session/notification bridge. Deliberately contains NO decoder. */
class BackgroundPlaybackService : Service() {
companion object {
private const val CHANNEL = "reelbox_playback"
private const val NOTIFICATION_ID = 3041
private const val UPDATE = "org.reelbox.app.UPDATE_PLAYBACK"
private const val CONTROL = "org.reelbox.app.MEDIA_CONTROL"
private var running: BackgroundPlaybackService? = null
fun start(context: Context, args: Map<*, *>) {
require(NativeMediaEvents.channel != null) { "No active Flutter media owner." }
val intent = Intent(context, BackgroundPlaybackService::class.java).setAction(UPDATE)
val snapshot = PlaybackSnapshot.from(args)
intent.putExtra("title", snapshot.title).putExtra("playing", snapshot.playing)
.putExtra("positionMs", snapshot.positionMs).putExtra("durationMs", snapshot.durationMs)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
fun update(args: Map<*, *>) { running?.applySnapshot(PlaybackSnapshot.from(args, running!!.snapshot)) }
fun stop(context: Context) { context.stopService(Intent(context, BackgroundPlaybackService::class.java)) }
}
private lateinit var session: MediaSession
private lateinit var audio: AudioManager
private var snapshot = PlaybackSnapshot()
private var focusRequest: AudioFocusRequest? = null
private var focusHeld = false
private var interrupted = false
private var resumeOnFocus = false
private var noisyRegistered = false
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
when (change) {
AudioManager.AUDIOFOCUS_GAIN -> {
interrupted = false
if (resumeOnFocus) NativeMediaEvents.action("play")
resumeOnFocus = false
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
resumeOnFocus = snapshot.playing
interrupted = true
NativeMediaEvents.action("pause")
}
AudioManager.AUDIOFOCUS_LOSS -> {
interrupted = false
resumeOnFocus = false
abandonFocus()
NativeMediaEvents.action("pause")
}
}
}
private val noisy = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) NativeMediaEvents.action("pause")
}
}
override fun onCreate() {
super.onCreate()
running = this
audio = getSystemService(AUDIO_SERVICE) as AudioManager
if (Build.VERSION.SDK_INT >= 26) {
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(
NotificationChannel(CHANNEL, "Background playback", NotificationManager.IMPORTANCE_LOW)
)
}
session = MediaSession(this, "ReelboxPlayback").apply {
setCallback(object : MediaSession.Callback() {
override fun onPlay() = NativeMediaEvents.action("play")
override fun onPause() = NativeMediaEvents.action("pause")
override fun onStop() { NativeMediaEvents.action("stop"); stopSelf() }
override fun onSeekTo(pos: Long) = NativeMediaEvents.action("seek", pos.coerceAtLeast(0))
override fun onSkipToNext() = NativeMediaEvents.action("next")
override fun onSkipToPrevious() = NativeMediaEvents.action("previous")
}, Handler(Looper.getMainLooper()))
isActive = true
}
val filter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
if (Build.VERSION.SDK_INT >= 33) registerReceiver(noisy, filter, Context.RECEIVER_NOT_EXPORTED)
else @Suppress("DEPRECATION") registerReceiver(noisy, filter)
noisyRegistered = true
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (NativeMediaEvents.channel == null || intent == null) { stopSelf(); return START_NOT_STICKY }
if (intent.action == CONTROL) {
val action = intent.getStringExtra("action")
if (action in listOf("play", "pause", "stop", "next", "previous")) NativeMediaEvents.action(action!!)
if (action == "stop") stopSelf()
} else if (intent.action == UPDATE) {
snapshot = PlaybackSnapshot.from(mapOf(
"title" to intent.getStringExtra("title"), "playing" to intent.getBooleanExtra("playing", false),
"positionMs" to intent.getLongExtra("positionMs", 0), "durationMs" to intent.getLongExtra("durationMs", 0)
), snapshot)
// Android 15 audio-focus eligibility requires foreground state BEFORE focus request.
updateSession()
if (Build.VERSION.SDK_INT >= 29) startForeground(NOTIFICATION_ID, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
else startForeground(NOTIFICATION_ID, notification())
updateFocus()
}
return START_NOT_STICKY
}
private fun applySnapshot(value: PlaybackSnapshot) {
snapshot = value
updateSession()
updateFocus()
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification())
}
private fun updateSession() {
session.setMetadata(MediaMetadata.Builder().putString(MediaMetadata.METADATA_KEY_TITLE, snapshot.title)
.putLong(MediaMetadata.METADATA_KEY_DURATION, snapshot.durationMs).build())
session.setPlaybackState(PlaybackState.Builder().setActions(
PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_PLAY_PAUSE or
PlaybackState.ACTION_STOP or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_SKIP_TO_NEXT or PlaybackState.ACTION_SKIP_TO_PREVIOUS
).setState(if (snapshot.playing) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED,
snapshot.positionMs, if (snapshot.playing) 1f else 0f).build())
}
@Suppress("DEPRECATION")
private fun updateFocus() {
if (!snapshot.playing) { if (!interrupted) abandonFocus(); return }
if (focusHeld) return
val granted = if (Build.VERSION.SDK_INT >= 26) {
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build())
.setWillPauseWhenDucked(true).setOnAudioFocusChangeListener(focusListener).build()
focusRequest = request
audio.requestAudioFocus(request)
} else audio.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
focusHeld = granted == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
if (!focusHeld) NativeMediaEvents.action("pause")
}
@Suppress("DEPRECATION")
private fun abandonFocus() {
if (Build.VERSION.SDK_INT >= 26) focusRequest?.let { audio.abandonAudioFocusRequest(it) }
else if (focusHeld) audio.abandonAudioFocus(focusListener)
focusHeld = false
focusRequest = null
}
private fun control(action: String): PendingIntent = PendingIntent.getService(this, action.hashCode(),
Intent(this, BackgroundPlaybackService::class.java).setAction(CONTROL).putExtra("action", action),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
private fun notification(): Notification {
val builder = if (Build.VERSION.SDK_INT >= 26) Notification.Builder(this, CHANNEL) else @Suppress("DEPRECATION") Notification.Builder(this)
val open = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
return builder.setSmallIcon(R.drawable.ic_playback).setContentTitle(snapshot.title).setContentText("Reelbox")
.setContentIntent(open).setVisibility(Notification.VISIBILITY_PUBLIC).setOnlyAlertOnce(true)
.setCategory(Notification.CATEGORY_TRANSPORT).setOngoing(snapshot.playing)
.setDeleteIntent(control("stop"))
.addAction(Notification.Action.Builder(android.R.drawable.ic_media_previous, "Previous", control("previous")).build())
.addAction(Notification.Action.Builder(if (snapshot.playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play,
if (snapshot.playing) "Pause" else "Play", control(if (snapshot.playing) "pause" else "play")).build())
.addAction(Notification.Action.Builder(android.R.drawable.ic_media_next, "Next", control("next")).build())
.addAction(Notification.Action.Builder(android.R.drawable.ic_menu_close_clear_cancel, "Stop", control("stop")).build())
.setStyle(Notification.MediaStyle().setMediaSession(session.sessionToken).setShowActionsInCompactView(0, 1, 2))
.build()
}
override fun onTaskRemoved(rootIntent: Intent?) { NativeMediaEvents.action("stop"); stopSelf(); super.onTaskRemoved(rootIntent) }
override fun onDestroy() {
if (running === this) running = null
abandonFocus()
if (noisyRegistered) unregisterReceiver(noisy)
session.isActive = false
session.release()
if (Build.VERSION.SDK_INT >= 24) stopForeground(STOP_FOREGROUND_REMOVE) else @Suppress("DEPRECATION") stopForeground(true)
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
@@ -0,0 +1,244 @@
package org.reelbox.reelbox
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.content.pm.ActivityInfo
import android.graphics.Color
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.Uri
import android.net.http.SslError
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.DownloadListener
import android.webkit.PermissionRequest
import android.webkit.SslErrorHandler
import android.webkit.URLUtil
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import java.io.ByteArrayInputStream
/** Restricted video embed host, not a vendor storefront or general external browser. */
class EmbeddedVideoActivity : Activity() {
companion object { private const val FILE_REQUEST = 6104 }
private lateinit var web: WebView
private lateinit var root: LinearLayout
private lateinit var screen: FrameLayout
private lateinit var status: TextView
private lateinit var home: String
private val resourceFetcher = RestrictedResourceFetcher()
@Volatile private var securePage = false
private var customView: View? = null
private var customCallback: WebChromeClient.CustomViewCallback? = null
private var oldOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private var oldUi = 0
private var fileCallback: ValueCallback<Array<Uri>>? = null
private var networkRegistered = false
private lateinit var connectivity: ConnectivityManager
private val network = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = updateConnectivity()
override fun onLost(network: Network) = updateConnectivity()
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) = updateConnectivity()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
home = intent.getStringExtra("url").orEmpty()
if (!NativeUrlPolicy.allowsRemote(home)) { finish(); return }
securePage = home.startsWith("https:", true)
title = intent.getStringExtra("title")?.take(512) ?: "Embedded video"
screen = FrameLayout(this)
root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setBackgroundColor(Color.BLACK) }
val tools = LinearLayout(this)
fun control(label: String, action: () -> Unit) {
tools.addView(Button(this).apply { text = label; setOnClickListener { action() } }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
}
control("Back") { back() }
control("Home") { load(home) }
control("Refresh") { web.reload() }
control("Close") { finish() }
root.addView(tools)
status = TextView(this).apply { setTextColor(Color.WHITE); setPadding(12, 4, 12, 4) }
root.addView(status)
web = WebView(this)
root.addView(web, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
screen.addView(root)
setContentView(screen)
web.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = false
allowContentAccess = false
javaScriptCanOpenWindowsAutomatically = false
setSupportMultipleWindows(false)
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
mediaPlaybackRequiresUserGesture = true
if (Build.VERSION.SDK_INT >= 26) safeBrowsingEnabled = true
}
CookieManager.getInstance().setAcceptThirdPartyCookies(web, false)
web.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val allowed = NativeUrlPolicy.allowsRemote(request.url.toString())
if (!allowed && request.isForMainFrame) message("This destination is not permitted.")
return !allowed
}
@Suppress("DEPRECATION")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean = !NativeUrlPolicy.allowsRemote(url)
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse {
val response = resourceFetcher.fetch(request.url.toString(), request.method, request.requestHeaders,
secureContext = !request.isForMainFrame && securePage)
if (request.isForMainFrame) {
if (response.effectiveUrl.isNotEmpty()) securePage = response.effectiveUrl.startsWith("https:", true)
if (response.statusCode in listOf(401, 403, 451)) {
message("This source or destination denied access. Authorization cannot be bypassed.")
} else if (response.statusCode >= 500) message("Video page unavailable. Check connectivity and use Refresh.")
}
return resourceResponse(response)
}
@Suppress("DEPRECATION")
override fun shouldInterceptRequest(view: WebView, url: String): WebResourceResponse {
return resourceResponse(resourceFetcher.fetch(url, secureContext = securePage))
}
override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
handler.cancel()
message("Secure connection failed. Playback was not authorized.")
}
override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, response: WebResourceResponse) {
if (request.isForMainFrame && response.statusCode in listOf(401, 403, 451)) {
view.stopLoading()
message("This source denied access. Reelbox cannot bypass authorization or DRM.")
}
}
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
if (request.isForMainFrame) message("Video page unavailable. Check connectivity and use Refresh.")
}
override fun onPageFinished(view: WebView, url: String) { updateConnectivity() }
}
web.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) { request.deny() }
override fun onShowCustomView(view: View, callback: CustomViewCallback) {
if (customView != null) { callback.onCustomViewHidden(); return }
customView = view
customCallback = callback
oldOrientation = requestedOrientation
@Suppress("DEPRECATION")
oldUi = window.decorView.systemUiVisibility
root.visibility = View.GONE
screen.addView(view, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
override fun onHideCustomView() = hideFullscreen()
override fun onShowFileChooser(view: WebView, callback: ValueCallback<Array<Uri>>, params: FileChooserParams): Boolean {
fileCallback?.onReceiveValue(null)
fileCallback = callback
val picker = Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*")
.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("video/*", "audio/*", "text/vtt", "application/x-subrip"))
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.mode == FileChooserParams.MODE_OPEN_MULTIPLE)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
return try { @Suppress("DEPRECATION") startActivityForResult(picker, FILE_REQUEST); true }
catch (_: Exception) { fileCallback?.onReceiveValue(null); fileCallback = null; false }
}
}
web.setDownloadListener(DownloadListener { url, _, contentDisposition, mime, _ ->
val name = URLUtil.guessFileName(url, contentDisposition, mime)
if (!NativeUrlPolicy.allowsRemote(url) || mime.orEmpty().contains("android.package", true) ||
Regex("\\.(apk|apks|xapk|aab)$", RegexOption.IGNORE_CASE).containsMatchIn(name)) {
message("This download is not permitted.")
} else AlertDialog.Builder(this).setTitle("Download media?")
.setMessage("Send $name to an installed download manager? Web credentials are not forwarded.")
.setNegativeButton("Cancel", null).setPositiveButton("Choose downloader") { _, _ ->
AlertDialog.Builder(this).setTitle("Download with")
.setItems(arrayOf("ADM", "1DM / IDM", "Other app")) { _, index ->
try {
val ok = NativeFileIntents(this).openDownloader(mapOf("url" to url, "filename" to name,
"packageName" to listOf("ADM", "IDM", "ask")[index]))
if (!ok) message("No compatible download manager is installed.")
} catch (_: Exception) { message("Unable to hand off this download.") }
}.show()
}.show()
})
connectivity = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
try { connectivity.registerDefaultNetworkCallback(network); networkRegistered = true } catch (_: Exception) { }
if (Build.VERSION.SDK_INT >= 33) onBackInvokedDispatcher.registerOnBackInvokedCallback(
android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT) { back() }
if (savedInstanceState != null) web.restoreState(savedInstanceState) else load(home)
updateConnectivity()
}
private fun load(url: String) {
if (NativeUrlPolicy.allowsRemote(url)) { hideFullscreen(); web.loadUrl(url) }
else message("This destination is not permitted.")
}
private fun resourceResponse(response: RestrictedResourceFetcher.Response): WebResourceResponse = try {
WebResourceResponse(response.mimeType, response.encoding, response.statusCode,
response.reasonPhrase, response.headers, response.body)
} catch (_: IllegalArgumentException) {
// Never fall back to WebView networking if Android rejects response metadata.
try { response.body.close() } catch (_: Exception) { }
WebResourceResponse("text/plain", "UTF-8", 502, "Unavailable", emptyMap(), ByteArrayInputStream(ByteArray(0)))
}
private fun message(text: String) { runOnUiThread { if (::status.isInitialized) status.text = text } }
private fun updateConnectivity() {
if (!::connectivity.isInitialized) return
val caps = connectivity.getNetworkCapabilities(connectivity.activeNetwork)
message(if (caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true) "Embedded video · network connected" else "Offline · reconnect and tap Refresh")
}
private fun hideFullscreen() {
val view = customView ?: return
screen.removeView(view)
customView = null
root.visibility = View.VISIBLE
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = oldUi
requestedOrientation = oldOrientation
customCallback?.onCustomViewHidden()
customCallback = null
}
private fun back() { if (customView != null) hideFullscreen() else if (web.canGoBack()) web.goBack() else finish() }
@Deprecated("System back uses OnBackInvokedDispatcher on Android 13+")
override fun onBackPressed() = back()
override fun onSaveInstanceState(outState: Bundle) { if (::web.isInitialized) web.saveState(outState); super.onSaveInstanceState(outState) }
@Deprecated("Activity result contract retained for the platform WebView file chooser")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode != FILE_REQUEST) return
val uris = if (resultCode == RESULT_OK) {
val clips = data?.clipData
if (clips != null) (0 until clips.itemCount.coerceAtMost(10)).map { clips.getItemAt(it).uri }
else listOfNotNull(data?.data)
} else emptyList()
fileCallback?.onReceiveValue(uris.filter { it.scheme == "content" }.takeIf { it.isNotEmpty() }?.toTypedArray())
fileCallback = null
}
override fun onPause() { if (::web.isInitialized) web.onPause(); super.onPause() }
override fun onResume() { super.onResume(); if (::web.isInitialized) web.onResume() }
override fun onDestroy() {
if (networkRegistered) connectivity.unregisterNetworkCallback(network)
fileCallback?.onReceiveValue(null)
fileCallback = null
hideFullscreen()
if (::web.isInitialized) {
web.stopLoading()
(web.parent as? ViewGroup)?.removeView(web)
web.destroy()
}
super.onDestroy()
}
}
@@ -0,0 +1,175 @@
package org.reelbox.reelbox
import android.app.PictureInPictureParams
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.util.Rational
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
/** Window-scoped brightness, system stream volume, ACTION_VIEW and Android PiP.
* No overlay, storage, write-settings or broad media permissions are requested. */
class MainActivity : FlutterActivity() {
private lateinit var mediaChannel: MethodChannel
private var playerActive = false
private var nativePlayerRequestId: String? = null
override fun onCreate(savedInstanceState: android.os.Bundle?) {
nativePlayerRequestId = savedInstanceState?.getString(NativePlayerRequests.REQUEST_ID)
super.onCreate(savedInstanceState)
}
override fun onSaveInstanceState(outState: android.os.Bundle) {
nativePlayerRequestId?.let { outState.putString(NativePlayerRequests.REQUEST_ID, it) }
super.onSaveInstanceState(outState)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode != NativePlayerRequests.REQUEST_CODE) return
val id = data?.getStringExtra(NativePlayerRequests.REQUEST_ID) ?: nativePlayerRequestId ?: return
if (id != NativePlayerRequests.results.activeId) return
if (data?.hasExtra("positionMs") == true) {
NativePlayerRequests.results.update(id, data.getLongExtra("positionMs", 0),
data.getLongExtra("durationMs", 0), data.getBooleanExtra("opened", false),
data.getBooleanExtra("completed", false))
}
nativePlayerRequestId = null
NativePlayerRequests.results.finish(id)
}
private var cast: ReelboxCast? = null
private fun castBridge(): ReelboxCast = cast ?: ReelboxCast(this, mediaChannel).also { cast = it }
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
mediaChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "org.reelbox/media")
NativeMediaEvents.channel = mediaChannel
mediaChannel.setMethodCallHandler { call, result ->
try {
when (call.method) {
"setPlayerActive" -> {
playerActive = call.argument<Boolean>("active") ?: false
result.success(null)
}
"isInPip" -> result.success(Build.VERSION.SDK_INT >= 26 && isInPictureInPictureMode)
"enterPip" -> result.success(enterPlayerPip())
"getVolume" -> {
val audio = getSystemService(AUDIO_SERVICE) as AudioManager
val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
result.success(if (max > 0) audio.getStreamVolume(AudioManager.STREAM_MUSIC).toDouble() / max else 0.0)
}
"setVolume" -> {
val audio = getSystemService(AUDIO_SERVICE) as AudioManager
val value = (call.argument<Number>("value")?.toDouble() ?: 0.5).coerceIn(0.0, 1.0)
val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
audio.setStreamVolume(AudioManager.STREAM_MUSIC, (value * max).toInt(), 0)
result.success(null)
}
"getBrightness" -> result.success(window.attributes.screenBrightness.toDouble())
"getEffectiveBrightness" -> {
val windowValue = window.attributes.screenBrightness
val value = if (windowValue >= 0) windowValue.toDouble() else
android.provider.Settings.System.getInt(contentResolver,
android.provider.Settings.System.SCREEN_BRIGHTNESS, 128) / 255.0
result.success(value.coerceIn(0.0, 1.0))
}
"setBrightness" -> {
val value = call.argument<Number>("value")?.toFloat() ?: -1f
val attributes = window.attributes
attributes.screenBrightness = if (value < 0) -1f else value.coerceIn(0.01f, 1f)
window.attributes = attributes
result.success(null)
}
"backgroundPlayback" -> {
if (call.argument<Boolean>("enabled") == true) {
if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 3041)
}
BackgroundPlaybackService.start(this, call.arguments as? Map<*, *> ?: emptyMap<Any, Any>())
} else BackgroundPlaybackService.stop(this)
result.success(null)
}
"backgroundPlaybackUpdate" -> {
BackgroundPlaybackService.update(call.arguments as? Map<*, *> ?: emptyMap<Any, Any>())
result.success(null)
}
"backgroundPlaybackStop" -> { BackgroundPlaybackService.stop(this); result.success(null) }
"castShowPicker" -> result.success(castBridge().showPicker())
"castGetState" -> result.success(castBridge().getState())
"castLoad" -> castBridge().load(call.arguments as? Map<*, *> ?: emptyMap<Any, Any>(), result)
"castCommand" -> castBridge().command(call.arguments as? Map<*, *> ?: emptyMap<Any, Any>(), result)
"castDisconnect" -> { castBridge().disconnect(); result.success(null) }
"openNativePlayer" -> {
val launch = NativePlayerLaunch.from(call.arguments as? Map<*, *> ?: emptyMap<Any, Any>(),
VlcPlayerActivity.ownedRoots(this))
val id = java.util.UUID.randomUUID().toString()
if (!NativePlayerRequests.results.begin(id, launch.positionMs) { result.success(it) }) {
result.error("NATIVE_PLAYER_BUSY", "A native player is already open.", null)
} else {
nativePlayerRequestId = id
try {
startActivityForResult(VlcPlayerActivity.intent(this, launch, id), NativePlayerRequests.REQUEST_CODE)
} catch (_: Exception) {
NativePlayerRequests.results.finish(id)
nativePlayerRequestId = null
}
}
}
"openExternal" -> result.success(NativeFileIntents(this).openExternal(call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()))
"openDownloader" -> result.success(NativeFileIntents(this).openDownloader(call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()))
"revealFile" -> result.success(NativeFileIntents(this).revealFile(call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()))
"openEmbedded" -> {
val url = call.argument<String>("url").orEmpty()
require(NativeUrlPolicy.allowsRemote(url))
startActivity(Intent(this, EmbeddedVideoActivity::class.java).putExtra("url", url)
.putExtra("title", call.argument<String>("title").orEmpty()))
result.success(true)
}
else -> result.notImplemented()
}
} catch (error: Exception) {
result.error("MEDIA_PLATFORM", "Android could not perform this media operation.", null)
}
}
}
override fun onDestroy() {
// Keep a pending reply through host configuration changes; a permanently
// closing host must not leak the result callback or native activity.
if (!isChangingConfigurations && nativePlayerRequestId != null &&
nativePlayerRequestId == NativePlayerRequests.results.activeId) {
finishActivity(NativePlayerRequests.REQUEST_CODE)
NativePlayerRequests.results.dispose()
nativePlayerRequestId = null
}
cast?.dispose()
cast = null
if (::mediaChannel.isInitialized && NativeMediaEvents.channel === mediaChannel) {
BackgroundPlaybackService.stop(this)
NativeMediaEvents.channel = null
mediaChannel.setMethodCallHandler(null)
}
super.onDestroy()
}
private fun enterPlayerPip(): Boolean {
if (Build.VERSION.SDK_INT < 26 || !packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) return false
return enterPictureInPictureMode(PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).build())
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
if (playerActive) enterPlayerPip()
}
override fun onPictureInPictureModeChanged(inPip: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(inPip, newConfig)
if (::mediaChannel.isInitialized) mediaChannel.invokeMethod("pipChanged", inPip)
}
}
@@ -0,0 +1,111 @@
package org.reelbox.reelbox
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import java.io.File
/** Only scoped content grants leave the app; never file:// or broad storage grants. */
class NativeFileIntents(private val activity: Activity) {
private fun localFile(path: String): File {
val file = File(path).canonicalFile
val roots = listOfNotNull(activity.filesDir, activity.cacheDir,
activity.getExternalFilesDir(null), activity.externalCacheDir).map { it.canonicalFile }
require(roots.any { file.path.startsWith(it.path + File.separator) } && file.isFile && file.canRead()) { "File is not in app-owned storage." }
require(!file.name.endsWith(".apk", true) && !file.name.endsWith(".apks", true) && !file.name.endsWith(".xapk", true) && !file.name.endsWith(".aab", true))
return file
}
private fun shareUri(file: File): Uri = FileProvider.getUriForFile(activity, "${activity.packageName}.files", file)
private fun mediaUri(value: String): Uri {
val parsed = Uri.parse(value)
return when (parsed.scheme?.lowercase()) {
"http", "https" -> { require(NativeUrlPolicy.allowsRemote(value)); parsed }
"content" -> {
require(!parsed.authority.isNullOrBlank())
// Confirm this process really has read access; forwarding arbitrary provider URIs is forbidden.
activity.contentResolver.openAssetFileDescriptor(parsed, "r")?.use { } ?: throw IllegalArgumentException("Unreadable content URI.")
parsed
}
"file" -> shareUri(localFile(parsed.path ?: ""))
null -> shareUri(localFile(value))
else -> throw IllegalArgumentException("Unsupported media URI.")
}
}
private fun grant(intent: Intent, uris: List<Uri>) {
val content = uris.filter { it.scheme == "content" }
if (content.isEmpty()) return
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.clipData = ClipData.newRawUri("Reelbox media", content.first()).also { clip ->
content.drop(1).forEach { clip.addItem(ClipData.Item(it)) }
}
}
private fun launch(intent: Intent, packageName: String?, title: String): Boolean {
if (!packageName.isNullOrBlank() && packageName != "ask") {
require(NativeUrlPolicy.safePackage(packageName))
try { activity.startActivity(Intent(intent).setPackage(packageName)); return true }
catch (_: ActivityNotFoundException) { /* Offer an installed alternative. */ }
}
return try {
activity.startActivity(Intent.createChooser(Intent(intent).setPackage(null), title)); true
} catch (_: ActivityNotFoundException) { false }
}
fun openExternal(args: Map<*, *>): Boolean {
val uri = mediaUri(args["url"] as? String ?: "")
val subtitle = (args["subtitleUrl"] as? String)?.takeIf { it.isNotBlank() }?.let { mediaUri(it) }
val position = ((args["positionMs"] as? Number)?.toLong() ?: 0).coerceAtLeast(0)
val requested = args["packageName"] as? String
val target = when (requested?.lowercase()) {
"vlc" -> "org.videolan.vlc"
"mx", "mxplayer" -> "com.mxtech.videoplayer.ad"
else -> requested
}
val intent = Intent(Intent.ACTION_VIEW).setDataAndType(uri, "video/*")
.putExtra(Intent.EXTRA_TITLE, (args["title"] as? String).orEmpty().take(512))
// VLC's public Android API uses a long position; MX Player uses int milliseconds.
if (target?.startsWith("com.mxtech.") == true) intent.putExtra("position", position.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
else intent.putExtra("position", position)
if (subtitle != null) {
intent.putExtra("subtitles_location", subtitle.toString()) // VLC Android public API.
intent.putExtra("subs", arrayOf(subtitle)) // MX Player public API.
intent.putExtra("subs.enable", arrayOf(subtitle))
}
grant(intent, listOfNotNull(uri, subtitle))
return launch(intent, target, "Open video with")
}
fun revealFile(args: Map<*, *>): Boolean {
val file = localFile(args["path"] as? String ?: "")
val uri = shareUri(file)
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension.lowercase()) ?: "application/octet-stream"
// Shares/export via Android's scoped chooser (including installed document/save targets).
val intent = Intent(Intent.ACTION_SEND).setType(mime).putExtra(Intent.EXTRA_STREAM, uri)
.putExtra(Intent.EXTRA_TITLE, file.name)
grant(intent, listOf(uri))
return launch(intent, "ask", "Export ${file.name}")
}
fun openDownloader(args: Map<*, *>): Boolean {
val url = args["url"] as? String ?: ""
require(NativeUrlPolicy.allowsRemote(url))
val requested = args["packageName"] as? String ?: "ask"
val target = when (requested.lowercase()) {
"adm" -> "com.dv.adm"
"idm", "1dm" -> "idm.internet.download.manager"
else -> requested
}
val name = (args["filename"] as? String).orEmpty().substringAfterLast('/').substringAfterLast('\\').take(240)
require(!Regex("\\.(apk|apks|xapk|aab)$", RegexOption.IGNORE_CASE).containsMatchIn(name))
val intent = Intent(Intent.ACTION_VIEW).setDataAndType(Uri.parse(url), "application/octet-stream")
.putExtra(Intent.EXTRA_TITLE, name)
return launch(intent, target, "Send download to")
}
}
@@ -0,0 +1,119 @@
package org.reelbox.reelbox
import java.io.File
import java.net.URI
import java.util.Locale
/** Immutable launch contract; remote source strings are never rewritten. */
data class NativePlayerLaunch(
val url: String,
val title: String,
val positionMs: Long,
val autoplay: Boolean,
val externalPackage: String,
val orientation: String,
val videoFit: String,
val speed: Float,
val subtitleEnabled: Boolean,
val subtitleUrl: String?,
val hardwareAcceleration: Boolean,
val pipEnabled: Boolean,
) {
companion object {
/** Accept remote HTTP(S), including a parent-owned loopback HLS lease,
* and readable canonical files strictly inside app-owned roots. No content
* providers, file authorities, APKs, arbitrary paths or URL credentials. */
fun source(value: String, roots: List<File>): String {
require(value.isNotBlank() && value.none { it.code < 32 || it.code == 127 })
if (value.startsWith('/')) return ownedFile(File(value), roots)
val uri = URI(value)
return when (uri.scheme?.lowercase(Locale.ROOT)) {
"http", "https" -> {
require(!uri.host.isNullOrBlank() && uri.rawUserInfo == null && !value.contains('\\'))
// A loopback lease is generated by this app, not catalog traffic.
val loopback = uri.host in listOf("127.0.0.1", "[::1]", "::1")
require(loopback || NativeUrlPolicy.allowsRemote(value))
value
}
"file" -> {
require(uri.rawAuthority.isNullOrEmpty() && uri.rawQuery == null && uri.rawFragment == null)
ownedFile(File(uri), roots)
}
else -> throw IllegalArgumentException("Unsupported media source")
}
}
private fun ownedFile(file: File, roots: List<File>): String {
val canonical = file.canonicalFile
require(roots.any { canonical.path.startsWith(it.canonicalPath + File.separator) })
require(canonical.isFile && canonical.canRead() && NativeUrlPolicy.allowsSharedFilename(canonical.name))
return canonical.path
}
fun from(args: Map<*, *>, roots: List<File>): NativePlayerLaunch {
val url = source(args["url"] as? String ?: "", roots)
val speed = (args["speed"] as? Number)?.toFloat() ?: 1f
require(speed.isFinite() && speed in 0.25f..3f)
val orientation = (args["orientation"] as? String) ?: "auto"
require(orientation in listOf("auto", "portrait", "landscape"))
val fit = (args["videoFit"] as? String) ?: "contain"
require(fit in listOf("contain", "cover", "fill"))
val external = (args["externalPackage"] as? String) ?: "ask"
require(external in listOf("ask", "vlc", "mx", "mxplayer") || NativeUrlPolicy.safePackage(external))
val position = args["positionMs"] as? Number
require(position == null || position.toDouble().isFinite())
return NativePlayerLaunch(url, (args["title"] as? String).orEmpty().take(512),
(position?.toLong() ?: 0).coerceAtLeast(0), args["autoplay"] != false,
external, orientation, fit, speed, args["subtitleEnabled"] != false,
(args["subtitleUrl"] as? String)?.takeIf { it.isNotBlank() }?.let { source(it, roots) },
args["hardwareAcceleration"] != false, args["pipEnabled"] != false)
}
}
}
/** Main-thread owned, process-retained rendezvous. Activity recreation does not
* resolve the Flutter call. Stale/duplicate activity results cannot consume a
* newer request. Clear before invoking callback to make reentrancy safe. */
class NativePlayerResultGate {
private data class Pending(val id: String, val reply: (Map<String, Any>) -> Unit,
var position: Long, var duration: Long = 0, var opened: Boolean = false, var completed: Boolean = false)
private var pending: Pending? = null
val activeId: String? get() = pending?.id
fun begin(id: String, positionMs: Long, reply: (Map<String, Any>) -> Unit): Boolean {
if (pending != null) return false
pending = Pending(id, reply, positionMs.coerceIn(0, Long.MAX_VALUE / 1000L))
return true
}
fun update(id: String, positionMs: Long, durationMs: Long, opened: Boolean, completed: Boolean): Boolean {
val item = pending?.takeIf { it.id == id } ?: return false
// Flutter Duration converts milliseconds to signed 64-bit microseconds.
item.duration = durationMs.coerceIn(0, Long.MAX_VALUE / 1000L)
item.position = positionMs.coerceIn(0, Long.MAX_VALUE / 1000L).let { if (item.duration > 0) it.coerceAtMost(item.duration) else it }
item.opened = item.opened || opened
item.completed = item.opened && completed
return true
}
fun finish(id: String): Boolean {
val item = pending?.takeIf { it.id == id } ?: return false
pending = null
// A permanently detached Flutter engine may reject a reply. The slot is
// still consumed exactly once; never retain an Activity/messenger or crash
// Android teardown while trying to notify an isolate that no longer exists.
try {
item.reply(mapOf("positionMs" to item.position, "durationMs" to item.duration,
"opened" to item.opened, "completed" to item.completed))
} catch (_: RuntimeException) { }
return true
}
fun dispose() { activeId?.let { finish(it) } }
}
object NativePlayerRequests {
val results = NativePlayerResultGate()
const val REQUEST_CODE = 3047
const val REQUEST_ID = "reelbox.nativePlayerRequestId"
}
@@ -0,0 +1,48 @@
package org.reelbox.reelbox
import java.net.URI
import java.net.URLDecoder
import java.text.Normalizer
import java.util.Locale
/** Outgoing media/browser policy only; never applied to catalog API traffic. */
object NativeUrlPolicy {
private val forbiddenHosts = listOf("t.me", "telegram.me", "telegram.org", "telegra.ph", "telegram.dog")
private val forbiddenPath = Regex("(^|[/_.?&=\\-])(apk|apks|xapk|aab|update|updates|updater|version|versions|support|contact|news|shop|store|telegram|subscribe|subscription|payment|login|register|drm|license)([/_.?&=\\-]|$)")
private val formatting = Regex("[\\p{Cf}\\u064b-\\u065f\\u0670\\u06d6-\\u06ed]")
private val entities = Regex("&#(x[0-9a-f]+|[0-9]+);", RegexOption.IGNORE_CASE)
private fun decode(value: String): String {
var decoded = value
repeat(3) { decoded = URLDecoder.decode(decoded, "UTF-8") }
return entities.replace(decoded) { match ->
val token = match.groupValues[1]
val number = if (token.startsWith("x", true)) token.drop(1).toIntOrNull(16) else token.toIntOrNull()
if (number != null && Character.isValidCodePoint(number)) String(Character.toChars(number)) else ""
}.replace("&amp;", "&").replace("&nbsp;", " ")
}
private fun normal(value: String): String = Normalizer.normalize(decode(value), Normalizer.Form.NFKC)
.lowercase(Locale.ROOT).replace('ي', 'ی').replace('ى', 'ی').replace('ك', 'ک').replace(formatting, "")
fun allowsRemote(value: String): Boolean = try {
val uri = URI(value)
val scheme = uri.scheme?.lowercase(Locale.ROOT)
val host = uri.host?.lowercase(Locale.ROOT)?.trimEnd('.') ?: ""
if (scheme !in listOf("http", "https") || host.isBlank() || uri.rawUserInfo != null || value.any { it.code <= 32 || it.code == 127 }) false
else {
val decoded = normal(value)
val compact = decoded.replace(Regex("[\\s_\\-\\u0640]"), "")
val vendor = listOf("melodybox", "melodibox", "norton", "ملودیباکس", "ملودیبکس", "نورتون", "نورتن").any { compact.contains(it) }
!vendor && !decoded.contains('\\') && !decoded.contains("تلگرام") &&
listOf("tg:", "telegram:", "intent:", "javascript:", "data:", "file:").none { decoded.contains(it) } &&
forbiddenHosts.none { host == it || host.endsWith(".$it") || decoded.contains("//$it/") || decoded.endsWith("//$it") } &&
!forbiddenPath.containsMatchIn(normal(uri.rawPath.orEmpty() + "?" + uri.rawQuery.orEmpty())) &&
!Regex("\\.(apk|apks|xapk|aab|exe|msi|dmg|ipa)([?#/]|$)").containsMatchIn(decoded)
}
} catch (_: Exception) { false }
fun allowsSharedFilename(value: String): Boolean = try {
!Regex("\\.(apk|apks|xapk|aab|exe|msi|dmg|ipa)$").containsMatchIn(normal(value).trim().trimEnd('.'))
} catch (_: Exception) { false }
fun safePackage(value: String): Boolean = Regex("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+").matches(value)
}
@@ -0,0 +1,20 @@
package org.reelbox.reelbox
/** Decoder truth supplied by Dart, never inferred by a native timer/player. */
data class PlaybackSnapshot(
val title: String = "Reelbox", val playing: Boolean = false,
val positionMs: Long = 0, val durationMs: Long = 0
) {
companion object {
fun from(args: Map<*, *>, previous: PlaybackSnapshot = PlaybackSnapshot()): PlaybackSnapshot {
val duration = ((args["durationMs"] as? Number)?.toLong() ?: previous.durationMs).coerceAtLeast(0)
val position = ((args["positionMs"] as? Number)?.toLong() ?: previous.positionMs).coerceAtLeast(0)
return PlaybackSnapshot(
(args["title"] as? String ?: previous.title).take(512),
args["playing"] as? Boolean ?: previous.playing,
if (duration > 0) position.coerceAtMost(duration) else position,
duration
)
}
}
}
@@ -0,0 +1,128 @@
package org.reelbox.reelbox
/** Distinguishes asynchronous dismissal callbacks from replaced native sheets. */
class PlayerMenuSession {
private var current: Any? = null
fun open(): Any = Any().also { current = it }
fun close(owner: Any): Boolean {
if (current !== owner) return false
current = null
return true
}
}
/** Timeline conversion shared by native controls and relative seek callbacks. */
object PlayerSeekMath {
fun fromProgress(durationMs: Long, progress: Int): Long {
val duration = durationMs.coerceAtLeast(0)
val fraction = progress.coerceIn(0, 10000).toLong()
// Each product is bounded independently; duration * fraction may overflow.
return (duration / 10000L) * fraction + (duration % 10000L) * fraction / 10000L
}
fun relative(positionMs: Long, deltaMs: Long): Long {
val position = positionMs.coerceAtLeast(0)
return if (deltaMs > 0 && position > Long.MAX_VALUE - deltaMs) Long.MAX_VALUE
else (position + deltaMs).coerceAtLeast(0)
}
}
/**
* Deterministic chrome policy. No Android dependency, wall clock, or timer.
* The view supplies monotonic milliseconds and schedules only [deadlineMs].
* Playback snapshots do not count as interaction and cannot extend a deadline.
*/
class PlayerControlsState {
private var visible = true
private var playing = false
private var loading = false
private var error = false
private var pip = false
private var inForeground = true
private var seeking = false
private var menuOpen = false
private var released = false
var locked = false
private set
val chromeVisible: Boolean get() = visible && !pip && !locked && !released
val unlockVisible: Boolean get() = locked && !pip && !released
var deadlineMs: Long? = null
private set
fun playback(playing: Boolean, loading: Boolean, error: Boolean, pip: Boolean, now: Long) {
if (released) return
val exitingPip = this.pip && !pip
val newError = error && !this.error
this.playing = playing
this.loading = loading
this.error = error
this.pip = pip
if (exitingPip || newError) visible = true
if (newError) { locked = false; seeking = false; menuOpen = false }
schedule(now)
}
fun tap(now: Long) {
if (released || pip || locked || error || seeking || menuOpen) return
visible = !visible
deadlineMs = null
schedule(now)
}
fun interact(now: Long) {
if (released || pip || locked) return
visible = true
deadlineMs = null
schedule(now)
}
fun menu(open: Boolean, now: Long) {
if (released || menuOpen == open) return
menuOpen = open
if (open) visible = true
deadlineMs = null
schedule(now)
}
fun seeking(active: Boolean, now: Long) {
if (released || seeking == active) return
seeking = active
if (active) visible = true
deadlineMs = null
schedule(now)
}
fun lock(locked: Boolean, now: Long) {
if (released || pip || error) return
this.locked = locked
visible = !locked
deadlineMs = null
schedule(now)
}
fun foreground(foreground: Boolean, now: Long) {
if (released || inForeground == foreground) return
inForeground = foreground
deadlineMs = null
schedule(now)
}
fun tick(now: Long) {
if (!released && deadlineMs?.let { now >= it } == true) {
visible = false
deadlineMs = null
}
}
fun release() {
released = true
visible = false
deadlineMs = null
}
private fun schedule(now: Long) {
if (!playing || loading || error || pip || locked || seeking || menuOpen || !inForeground || !chromeVisible) {
deadlineMs = null
} else if (deadlineMs == null) deadlineMs = now + 3000L
}
}
@@ -0,0 +1,617 @@
package org.reelbox.reelbox
import android.app.Dialog
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.graphics.drawable.ClipDrawable
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.LayerDrawable
import android.graphics.drawable.RippleDrawable
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.text.TextUtils
import android.view.GestureDetector
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.Window
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.ScrollView
import android.widget.SeekBar
import android.widget.TextView
import java.util.Locale
/** Immutable engine snapshot. Repeated snapshots are not user interactions. */
data class PlayerOverlayState(
val title: String,
val positionMs: Long = 0L,
val durationMs: Long = 0L,
val playing: Boolean = false,
val loading: Boolean = false,
val error: Boolean = false,
val seekable: Boolean = false,
val speed: Float = 1f,
val pip: Boolean = false,
)
/** Cinema chrome only: no decoder, URL, history, or activity ownership. */
class PlayerOverlayView(context: Context, private val callbacks: Callbacks) : FrameLayout(context) {
interface Callbacks {
fun onClose()
fun onTogglePlayback()
fun onSeekRelative(deltaMs: Long)
fun onSeekTo(positionMs: Long)
fun onSettings()
fun onExternal()
fun onRetry(software: Boolean)
fun onPictureInPicture()
}
private val handler = Handler(Looper.getMainLooper())
private val controls = PlayerControlsState()
private var state = PlayerOverlayState("")
private var released = false
private var dragging = false
private var cancellingDrag = false
private var suppressCancelledTouch = false
private var scheduledDeadline: Long? = null
private val safeInsets = android.graphics.Rect()
private val header = LinearLayout(context)
private val gestures = View(context).apply {
tag = "player-video-gestures"
contentDescription = "Video. Tap for controls; double-tap left or right to seek ten seconds"
isClickable = true
isFocusable = true
}
private val chrome = LinearLayout(context).apply {
tag = "player-controls"
orientation = LinearLayout.VERTICAL
clipChildren = true
}
private val title = label(16f).apply {
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
}
private val play = icon("play", "Play", "player-play") { callbacks.onTogglePlayback() }
private val back = icon("back", "Close player", "player-back") { callbacks.onClose() }
private val external = icon("external", "Open external player", "player-external") { callbacks.onExternal() }
private val settings = icon("settings", "Player settings", "player-settings") { callbacks.onSettings() }
private val lock = icon("lock", "Lock screen controls", "player-lock") {
controls.lock(true, now()); updateVisibility()
}
private val unlock = icon("unlock", "Unlock screen controls", "player-unlock") {
controls.lock(false, now()); updateVisibility()
}
private val rewind = icon("rewind", "Rewind ten seconds", "player-rewind") { callbacks.onSeekRelative(-10000L) }
private val forward = icon("forward", "Forward ten seconds", "player-forward") { callbacks.onSeekRelative(10000L) }
private val pip = icon("pip", "Picture in picture", "player-pip") { callbacks.onPictureInPicture() }
private val elapsed = label(12f).apply { tag = "player-clock"; gravity = Gravity.CENTER }
private val remaining = label(12f).apply { tag = "player-remaining"; gravity = Gravity.CENTER }
private val speed = label(12f).apply { gravity = Gravity.CENTER_VERTICAL; setTextColor(0xFFD0D0D0.toInt()) }
private val seek = SeekBar(context).apply {
tag = "player-seek"
contentDescription = "Seek video"
max = 10000
progressTintList = ColorStateList.valueOf(CORAL)
progressBackgroundTintList = ColorStateList.valueOf(0xFF666666.toInt())
thumbTintList = ColorStateList.valueOf(Color.WHITE)
splitTrack = false
minimumHeight = dp(48)
// Keep a 48dp touch area and a 2dp track, without API-29 min/max
// ProgressBar setters (the player also supports Android 24).
progressDrawable = LayerDrawable(arrayOf(
playerSurface(0xFF666666.toInt(), dp(1).toFloat()),
ClipDrawable(playerSurface(CORAL, dp(1).toFloat()), Gravity.LEFT, ClipDrawable.HORIZONTAL),
)).apply {
setId(0, android.R.id.background); setId(1, android.R.id.progress)
for (layer in 0..1) { setLayerHeight(layer, dp(2)); setLayerGravity(layer, Gravity.CENTER_VERTICAL) }
}
thumb = GradientDrawable().apply {
shape = GradientDrawable.OVAL; setColor(Color.WHITE); setSize(dp(10), dp(10))
}
}
private val loading = ProgressBar(context).apply {
tag = "player-loading"
contentDescription = "Buffering video"
indeterminateTintList = ColorStateList.valueOf(Color.WHITE)
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
}
private val errorPanel = LinearLayout(context).apply {
tag = "player-error"
orientation = LinearLayout.VERTICAL
setPadding(dp(20), dp(16), dp(20), dp(12))
background = playerSurface(0xF0242424.toInt(), dp(16).toFloat())
}
private val errorScroll = ScrollView(context).apply { isFillViewport = false; addView(errorPanel) }
private val center = FrameLayout(context)
private val transport = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL }
private val bottom = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
private val feedback = label(14f).apply {
gravity = Gravity.CENTER
setPadding(dp(12), dp(8), dp(12), dp(8))
background = playerSurface(0xD0242424.toInt(), dp(16).toFloat())
visibility = View.GONE
}
private val hideFeedback = Runnable { if (!released) feedback.visibility = View.GONE }
private val hide = Runnable {
scheduledDeadline = null
if (!released) { controls.tick(now()); updateVisibility() }
}
private val detector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onDown(event: MotionEvent) = true
override fun onSingleTapConfirmed(event: MotionEvent): Boolean {
if (!released) gestures.performClick()
return true
}
override fun onDoubleTap(event: MotionEvent): Boolean {
if (!released && !state.pip && !controls.locked && !state.error && state.seekable) {
val delta = if (event.x < gestures.width / 2f) -10000L else 10000L
controls.interact(now())
callbacks.onSeekRelative(delta)
if (released) return true
feedback.text = if (delta < 0) "−10 s" else "+10 s"
(feedback.layoutParams as LayoutParams).apply {
gravity = Gravity.CENTER_VERTICAL or if (delta < 0) Gravity.LEFT else Gravity.RIGHT
leftMargin = dp(24); rightMargin = dp(24)
feedback.layoutParams = this
}
feedback.visibility = View.VISIBLE
handler.removeCallbacks(hideFeedback)
handler.postDelayed(hideFeedback, 700)
updateVisibility()
}
return true
}
}, handler).apply { setIsLongpressEnabled(false) }
init {
// The only gesture detector is a sibling BELOW chrome. Buttons receive
// ACTION_UP immediately; they never wait for double-tap confirmation.
addView(gestures, LayoutParams(-1, -1))
addView(chrome, LayoutParams(-1, -1))
header.apply {
gravity = Gravity.CENTER_VERTICAL
background = scrim(true)
addView(back, LinearLayout.LayoutParams(dp(48), dp(48)))
addView(title, LinearLayout.LayoutParams(0, dp(48), 1f))
addView(View(context), LinearLayout.LayoutParams(dp(32), dp(48)))
addView(external, LinearLayout.LayoutParams(dp(48), dp(48)))
}
chrome.addView(header, LinearLayout.LayoutParams(-1, dp(48)))
transport.addView(rewind, LinearLayout.LayoutParams(dp(48), dp(48)))
transport.addView(play, LinearLayout.LayoutParams(dp(64), dp(64)).apply {
leftMargin = dp(20); rightMargin = dp(20)
})
transport.addView(forward, LinearLayout.LayoutParams(dp(48), dp(48)))
play.background = playerSurface(0xB8242424.toInt(), dp(32).toFloat())
center.addView(transport, LayoutParams(-2, -2, Gravity.CENTER))
chrome.addView(center, LinearLayout.LayoutParams(-1, 0, 1f))
bottom.background = scrim(false)
val timeline = LinearLayout(context).apply {
gravity = Gravity.CENTER_VERTICAL
addView(elapsed, LinearLayout.LayoutParams(dp(60), dp(48)))
addView(seek, LinearLayout.LayoutParams(0, dp(48), 1f))
addView(remaining, LinearLayout.LayoutParams(dp(68), dp(48)))
}
bottom.addView(timeline, LinearLayout.LayoutParams(-1, dp(48)))
val footer = LinearLayout(context).apply {
gravity = Gravity.CENTER_VERTICAL
addView(lock, LinearLayout.LayoutParams(dp(48), dp(48)))
addView(speed, LinearLayout.LayoutParams(0, dp(48), 1f))
addView(pip, LinearLayout.LayoutParams(dp(48), dp(48)))
addView(settings, LinearLayout.LayoutParams(dp(48), dp(48)))
}
bottom.addView(footer, LinearLayout.LayoutParams(-1, dp(48)))
chrome.addView(bottom, LinearLayout.LayoutParams(-1, -2))
errorPanel.addView(label(18f).apply {
text = "Unable to play this video"
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
}, LinearLayout.LayoutParams(-1, -2))
errorPanel.addView(label(14f).apply {
text = "Check your connection and retry, or open this source in another player."
setTextColor(0xFFCCCCCC.toInt())
setPadding(0, dp(8), 0, dp(12))
}, LinearLayout.LayoutParams(-1, -2))
val recovery = LinearLayout(context)
recovery.addView(action("Retry", "player-retry") { callbacks.onRetry(false) },
LinearLayout.LayoutParams(0, dp(48), 1f).apply { rightMargin = dp(8) })
recovery.addView(action("External player", "player-error-external") { callbacks.onExternal() },
LinearLayout.LayoutParams(0, dp(48), 1f))
errorPanel.addView(recovery, LinearLayout.LayoutParams(-1, -2))
errorPanel.addView(action("Try software decoding", "player-retry-software", subtle = true) {
callbacks.onRetry(true)
}, LinearLayout.LayoutParams(-1, dp(48)))
addView(errorScroll, LayoutParams(-1, -2, Gravity.CENTER).apply {
leftMargin = dp(20); rightMargin = dp(20)
topMargin = dp(56); bottomMargin = dp(8)
})
addView(loading, LayoutParams(dp(24), dp(24), Gravity.CENTER))
addView(unlock, LayoutParams(dp(48), dp(48), Gravity.RIGHT or Gravity.CENTER_VERTICAL).apply {
rightMargin = dp(16)
})
unlock.background = playerSurface(0xB8242424.toInt(), dp(24).toFloat())
addView(feedback, LayoutParams(-2, -2, Gravity.CENTER))
gestures.setOnClickListener {
if (!released) { controls.tap(now()); updateVisibility() }
}
gestures.setOnTouchListener { _, event -> detector.onTouchEvent(event); true }
seek.setOnTouchListener { _, event ->
when {
released -> true
event.actionMasked == MotionEvent.ACTION_DOWN -> {
suppressCancelledTouch = false
false
}
suppressCancelledTouch -> true
event.actionMasked == MotionEvent.ACTION_CANCEL -> {
suppressCancelledTouch = true
cancelActiveScrub(); updateVisibility(); true
}
else -> false
}
}
seek.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onStartTrackingTouch(bar: SeekBar) {
if (released) return
dragging = true
controls.seeking(true, now()); updateVisibility()
}
override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
if (released || !fromUser || !canSeek()) return
val target = PlayerSeekMath.fromProgress(state.durationMs, progress)
updateClock(target)
if (!dragging) {
// Keyboard/accessibility changes never send onStopTrackingTouch.
controls.interact(now())
callbacks.onSeekTo(target)
updateVisibility()
}
}
override fun onStopTrackingTouch(bar: SeekBar) {
if (released || !dragging) return
dragging = false
controls.seeking(false, now())
if (cancellingDrag) return
if (canSeek()) {
callbacks.onSeekTo(PlayerSeekMath.fromProgress(state.durationMs, bar.progress))
}
updateVisibility()
}
})
updateVisibility()
}
fun render(state: PlayerOverlayState) {
if (released) return
this.state = state
controls.playback(state.playing, state.loading, state.error, state.pip, now())
title.text = state.title.ifBlank { "ReelBox" }
play.setImageDrawable(PlayerIcon(if (state.playing) "pause" else "play", intrinsicSize = dp(28)))
play.contentDescription = if (state.playing) "Pause" else "Play"
play.isEnabled = !state.error
syncSeekEnabled()
rewind.isEnabled = state.seekable && !state.error; forward.isEnabled = rewind.isEnabled
rewind.alpha = if (rewind.isEnabled) 1f else .4f
forward.alpha = rewind.alpha
speed.text = "${state.speed.toString().removeSuffix(".0")}×"
if (!dragging) {
seek.progress = if (state.durationMs > 0)
(state.positionMs.toDouble() / state.durationMs * 10000).toInt().coerceIn(0, 10000) else 0
updateClock(state.positionMs)
}
updateVisibility()
}
fun setMenuOpen(open: Boolean) {
if (!released) { controls.menu(open, now()); updateVisibility() }
}
fun setForeground(foreground: Boolean) {
if (!released) { controls.foreground(foreground, now()); updateVisibility() }
}
/** Inset controls, not scrims: their backgrounds must reach every edge. */
fun setSafeInsets(left: Int, top: Int, right: Int, bottom: Int) {
if (released) return
if (safeInsets == android.graphics.Rect(left, top, right, bottom)) return
safeInsets.set(left, top, right, bottom)
header.setPadding(left, top, right, 0)
header.layoutParams = (header.layoutParams as LinearLayout.LayoutParams).apply { height = dp(48) + top }
this.bottom.setPadding(left, 0, right, bottom)
center.setPadding(left, 0, right, 0)
errorScroll.layoutParams = (errorScroll.layoutParams as LayoutParams).apply {
leftMargin = dp(20) + left; rightMargin = dp(20) + right
topMargin = dp(56) + top; bottomMargin = dp(8) + bottom
}
unlock.layoutParams = (unlock.layoutParams as LayoutParams).apply { rightMargin = dp(16) + right }
updateVisibility()
}
fun release() {
if (released) return
released = true
controls.release()
scheduledDeadline = null
handler.removeCallbacksAndMessages(null)
// GestureDetector owns an internal Handler; CANCEL clears its pending taps.
val cancel = MotionEvent.obtain(now(), now(), MotionEvent.ACTION_CANCEL, 0f, 0f, 0)
detector.onTouchEvent(cancel)
cancel.recycle()
gestures.setOnTouchListener(null)
gestures.setOnClickListener(null)
seek.setOnSeekBarChangeListener(null)
// Freeze the last frame until the activity detaches; release must not
// flash a new chrome state or let a pending confirmed tap change it.
}
private fun updateClock(position: Long) {
elapsed.text = playerTime(position)
elapsed.contentDescription = "Elapsed ${elapsed.text}"
remaining.text = if (state.durationMs > 0) "−${playerTime((state.durationMs - position).coerceAtLeast(0))}" else "—:—"
remaining.contentDescription = if (state.durationMs > 0) "Remaining ${remaining.text}" else "Remaining time unknown"
}
private fun updateVisibility() {
if (released) return
syncSeekEnabled()
gestures.visibility = if (state.pip) View.GONE else View.VISIBLE
importantForAccessibility = if (state.pip) View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
else View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
chrome.visibility = if (controls.chromeVisible && !state.pip) View.VISIBLE else View.GONE
unlock.visibility = if (controls.unlockVisible && !state.pip) View.VISIBLE else View.GONE
loading.visibility = if (state.loading && !state.error && !state.pip && !controls.locked) View.VISIBLE else View.GONE
errorScroll.visibility = if (state.error && !state.pip) View.VISIBLE else View.GONE
errorPanel.visibility = errorScroll.visibility
transport.visibility = if (state.error) View.GONE else View.VISIBLE
bottom.visibility = if (state.error) View.GONE else View.VISIBLE
if (state.pip || controls.locked || state.error) feedback.visibility = View.GONE
(loading.layoutParams as LayoutParams).apply {
gravity = if (controls.chromeVisible) Gravity.TOP or Gravity.RIGHT else Gravity.CENTER
topMargin = if (controls.chromeVisible) dp(12) + safeInsets.top else 0
rightMargin = if (controls.chromeVisible) dp(60) + safeInsets.right else 0
loading.layoutParams = this
}
if (scheduledDeadline != controls.deadlineMs) {
handler.removeCallbacks(hide)
scheduledDeadline = controls.deadlineMs
scheduledDeadline?.let { handler.postDelayed(hide, (it - now()).coerceAtLeast(0)) }
}
}
private fun canSeek() = state.seekable && state.durationMs > 0 && !state.error && !state.pip && !controls.locked
private fun syncSeekEnabled() {
if (!canSeek()) cancelActiveScrub()
seek.isEnabled = canSeek()
}
private fun cancelActiveScrub() {
if (!dragging || cancellingDrag) return
// Parent touch targets can outlive a disabled/hidden SeekBar. Consume
// the rest of this finger stream even if playback recovers before UP;
// only a fresh DOWN may rearm touch seeking. Non-touch input is separate.
suppressCancelledTouch = true
cancellingDrag = true
val cancel = MotionEvent.obtain(now(), now(), MotionEvent.ACTION_CANCEL, 0f, 0f, 0)
try {
// AbsSeekBar must see CANCEL while still enabled to clear its own
// tracking state. Its onStop callback is cancellation, never a seek.
seek.onTouchEvent(cancel)
} finally {
cancel.recycle()
seek.isPressed = false
dragging = false
controls.seeking(false, now())
cancellingDrag = false
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// Resolve responsive sizes before children measure, including inset-only
// changes where onSizeChanged would not run (rotation/PiP transitions).
val w = MeasureSpec.getSize(widthMeasureSpec)
val h = MeasureSpec.getSize(heightMeasureSpec)
val size = if (h - safeInsets.top - safeInsets.bottom < dp(240)) dp(48) else dp(64)
val playParams = play.layoutParams as LinearLayout.LayoutParams
if (playParams.width != size || playParams.height != size) {
play.layoutParams = playParams.apply { width = size; height = size }
}
val errorWidth = (w - safeInsets.left - safeInsets.right - dp(40)).coerceAtMost(dp(440)).coerceAtLeast(0)
val errorParams = errorScroll.layoutParams as LayoutParams
if (errorParams.width != errorWidth) errorScroll.layoutParams = errorParams.apply { width = errorWidth }
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
private fun icon(kind: String, description: String, viewTag: String, action: () -> Unit) = ImageView(context).apply {
tag = viewTag
contentDescription = description
setImageDrawable(PlayerIcon(kind, intrinsicSize = dp(24)))
scaleType = ImageView.ScaleType.CENTER_INSIDE
setPadding(dp(12), dp(12), dp(12), dp(12))
background = RippleDrawable(ColorStateList.valueOf(0x35FFFFFF), null, playerSurface(Color.WHITE, dp(24).toFloat()))
isFocusable = true
setOnClickListener {
if (!released && !state.pip && (!controls.locked || viewTag == "player-unlock")) {
controls.interact(now()); action(); if (!released) updateVisibility()
}
}
}
private fun action(text: String, viewTag: String, subtle: Boolean = false, action: () -> Unit) = label(if (subtle) 12f else 14f).apply {
tag = viewTag; this.text = text; contentDescription = text
gravity = Gravity.CENTER; maxLines = 2
setPadding(dp(8), dp(4), dp(8), dp(4))
setTextColor(if (subtle) 0xFFBDBDBD.toInt() else Color.WHITE)
if (!subtle) background = playerSurface(0xFF3A3A3A.toInt(), dp(12).toFloat())
isFocusable = true
setOnClickListener {
if (!released && !state.pip) {
controls.interact(now()); action(); if (!released) updateVisibility()
}
}
}
private fun label(size: Float) = TextView(context).apply {
textSize = size; setTextColor(Color.WHITE)
typeface = Typeface.create("sans-serif", Typeface.NORMAL)
includeFontPadding = false
}
private fun scrim(top: Boolean) = GradientDrawable(
if (top) GradientDrawable.Orientation.TOP_BOTTOM else GradientDrawable.Orientation.BOTTOM_TOP,
intArrayOf(0xC9000000.toInt(), 0x00000000),
)
private fun dp(value: Int) = (value * resources.displayMetrics.density + .5f).toInt()
private fun now() = SystemClock.uptimeMillis()
companion object { private val CORAL = Color.rgb(255, 130, 118) }
}
private fun playerTime(ms: Long): String {
val seconds = ms.coerceAtLeast(0) / 1000
return if (seconds >= 3600) String.format(Locale.ROOT, "%d:%02d:%02d", seconds / 3600, seconds / 60 % 60, seconds % 60)
else String.format(Locale.ROOT, "%d:%02d", seconds / 60, seconds % 60)
}
internal fun playerSurface(color: Int, radius: Float) = GradientDrawable().apply {
setColor(color); cornerRadius = radius
}
/** One consistent 24-unit, rounded 1.7-unit stroke family; no font glyph icons. */
internal class PlayerIcon(
private val kind: String,
private val tint: Int = Color.WHITE,
private val intrinsicSize: Int = 24,
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = tint; style = Paint.Style.STROKE; strokeWidth = 1.7f
strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND
}
override fun draw(canvas: Canvas) {
canvas.save()
canvas.translate(bounds.left.toFloat(), bounds.top.toFloat())
canvas.scale(bounds.width() / 24f, bounds.height() / 24f)
fun line(vararg coordinates: Float) {
val path = Path(); path.moveTo(coordinates[0], coordinates[1])
for (i in 2 until coordinates.size step 2) path.lineTo(coordinates[i], coordinates[i + 1])
canvas.drawPath(path, paint)
}
when (kind) {
"play" -> line(9f, 5f, 19f, 12f, 9f, 19f, 9f, 5f)
"pause" -> { line(8f, 5f, 8f, 19f); line(16f, 5f, 16f, 19f) }
"back" -> { line(14f, 5f, 7f, 12f, 14f, 19f); line(7f, 12f, 21f, 12f) }
"external" -> {
line(14f, 4f, 21f, 4f, 21f, 11f); line(21f, 4f, 11f, 14f)
line(10f, 5f, 4f, 5f, 4f, 20f, 19f, 20f, 19f, 14f)
}
"pip" -> { canvas.drawRoundRect(3f, 5f, 21f, 19f, 1f, 1f, paint); canvas.drawRect(12f, 11f, 18f, 16f, paint) }
"lock", "unlock" -> {
canvas.drawRoundRect(5f, 10f, 19f, 21f, 2f, 2f, paint)
if (kind == "lock") canvas.drawArc(8f, 2f, 16f, 13f, 180f, 180f, false, paint)
else canvas.drawArc(8f, 2f, 18f, 13f, 180f, 150f, false, paint)
line(12f, 14f, 12f, 17f)
}
"settings" -> {
line(4f, 7f, 20f, 7f); line(4f, 17f, 20f, 17f)
canvas.drawCircle(9f, 7f, 3f, Paint(paint).apply { style = Paint.Style.FILL; color = 0xFF242424.toInt() })
canvas.drawCircle(9f, 7f, 3f, paint)
canvas.drawCircle(15f, 17f, 3f, Paint(paint).apply { style = Paint.Style.FILL; color = 0xFF242424.toInt() })
canvas.drawCircle(15f, 17f, 3f, paint)
}
"rewind", "forward" -> {
canvas.save()
if (kind == "forward") { canvas.translate(24f, 0f); canvas.scale(-1f, 1f) }
canvas.drawArc(4f, 4f, 21f, 21f, -105f, 285f, false, paint)
line(4f, 3f, 4f, 9f, 10f, 9f)
canvas.restore()
// Numerals are paths, not device-dependent emoji/icon glyphs.
line(9f, 12f, 10f, 11f, 10f, 17f)
canvas.drawRoundRect(13f, 11f, 17f, 17f, 1.5f, 1.5f, paint)
}
"check" -> line(5f, 12f, 10f, 17f, 20f, 7f)
"close" -> { line(6f, 6f, 18f, 18f); line(18f, 6f, 6f, 18f) }
}
canvas.restore()
}
override fun getIntrinsicWidth() = intrinsicSize
override fun getIntrinsicHeight() = intrinsicSize
override fun setAlpha(alpha: Int) { paint.alpha = alpha; invalidateSelf() }
override fun setColorFilter(filter: ColorFilter?) { paint.colorFilter = filter; invalidateSelf() }
@Suppress("OVERRIDE_DEPRECATION") override fun getOpacity() = PixelFormat.TRANSLUCENT
}
/** Bottom-anchored native sheet, scrollable at small landscape heights/font sizes. */
internal fun playerChoiceSheet(
context: Context, title: String, values: List<String>, selected: Int,
dismissed: () -> Unit, pick: (Int) -> Unit,
): Dialog {
fun dp(value: Int) = (value * context.resources.displayMetrics.density + .5f).toInt()
val dialog = Dialog(context)
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
val panel = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(12), dp(8), dp(12), dp(12))
background = playerSurface(0xFF242424.toInt(), dp(20).toFloat())
}
val heading = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL }
heading.addView(TextView(context).apply {
text = title; textSize = 18f; setTextColor(Color.WHITE)
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
setPadding(dp(12), 0, dp(8), 0)
}, LinearLayout.LayoutParams(0, -2, 1f))
heading.addView(ImageView(context).apply {
contentDescription = "Close settings"
setImageDrawable(PlayerIcon("close", intrinsicSize = dp(24))); setPadding(dp(12), dp(12), dp(12), dp(12))
isFocusable = true; setOnClickListener { dialog.dismiss() }
}, LinearLayout.LayoutParams(dp(48), dp(48)))
panel.addView(heading, LinearLayout.LayoutParams(-1, -2))
val choices = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
values.forEachIndexed { index, value ->
val checked = index == selected
val row = LinearLayout(context).apply {
gravity = Gravity.CENTER_VERTICAL
minimumHeight = dp(56)
setPadding(dp(12), dp(8), dp(12), dp(8))
background = playerSurface(if (checked) 0xFF333333.toInt() else 0xFF242424.toInt(), dp(12).toFloat())
contentDescription = "$value${if (checked) ", selected" else ""}"
isSelected = checked; isFocusable = true
setOnClickListener { dialog.dismiss(); pick(index) }
}
row.addView(TextView(context).apply {
text = value; textSize = 15f; setTextColor(Color.WHITE)
setPadding(0, 0, dp(12), 0)
}, LinearLayout.LayoutParams(0, -2, 1f))
row.addView(ImageView(context).apply {
if (checked) setImageDrawable(PlayerIcon("check", Color.rgb(255, 130, 118)))
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}, LinearLayout.LayoutParams(dp(24), dp(24)))
choices.addView(row, LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(4) })
}
panel.addView(ScrollView(context).apply { addView(choices) }, LinearLayout.LayoutParams(-1, 0, 1f))
dialog.setContentView(panel)
dialog.setOnDismissListener { dismissed() }
dialog.window?.apply {
setBackgroundDrawableResource(android.R.color.transparent)
addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
setDimAmount(.55f)
setGravity(Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL)
val metrics = context.resources.displayMetrics
val contentHeight = dp(76 + values.size * 60)
setLayout((metrics.widthPixels - dp(24)).coerceAtMost(dp(560)),
contentHeight.coerceAtMost((metrics.heightPixels * .82f).toInt()))
}
dialog.show()
return dialog
}
@@ -0,0 +1,173 @@
package org.reelbox.reelbox
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.mediarouter.app.MediaRouteButton
import com.google.android.gms.cast.CastMediaControlIntent
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaLoadRequestData
import com.google.android.gms.cast.MediaMetadata
import com.google.android.gms.cast.MediaSeekOptions
import com.google.android.gms.cast.MediaTrack
import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.CastSession
import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionManagerListener
import com.google.android.gms.cast.framework.SessionProvider
import com.google.android.gms.cast.framework.media.CastMediaOptions
import com.google.android.gms.cast.framework.media.RemoteMediaClient
import com.google.android.gms.common.api.PendingResult
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.TimeUnit
/** Default Media Receiver: no server credentials, DRM keys, or custom receiver app. */
class ReelboxCastOptions : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions = CastOptions.Builder()
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
.setCastMediaOptions(CastMediaOptions.Builder().setNotificationOptions(null).setMediaSessionEnabled(false).build())
.setEnableReconnectionService(false)
.build()
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? = null
}
/** Support-fragment host kept separate from the Flutter activity/theme. */
class CastPickerActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
title = "Cast video"
val button = MediaRouteButton(this)
button.contentDescription = "Choose a Cast receiver"
setContentView(button, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
try {
CastButtonFactory.setUpMediaRouteButton(this, button)
if (savedInstanceState == null) button.post { button.showDialog() }
} catch (_: Exception) {
android.widget.Toast.makeText(this, "Google Cast is unavailable on this device.", android.widget.Toast.LENGTH_LONG).show()
finish()
}
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
override fun onSupportNavigateUp(): Boolean { finish(); return true }
}
class ReelboxCast(private val activity: MainActivity, private val channel: MethodChannel) {
private var context: CastContext? = null
private val handler = Handler(Looper.getMainLooper())
private var disposed = false
private val poll = object : Runnable {
override fun run() {
if (disposed) return
emit()
handler.postDelayed(this, 1000)
}
}
private val listener = object : SessionManagerListener<CastSession> {
override fun onSessionStarting(session: CastSession) = emit()
override fun onSessionStarted(session: CastSession, id: String) = emit()
override fun onSessionStartFailed(session: CastSession, error: Int) = emit()
override fun onSessionEnding(session: CastSession) = emit()
override fun onSessionEnded(session: CastSession, error: Int) = emit()
override fun onSessionResuming(session: CastSession, id: String) = emit()
override fun onSessionResumed(session: CastSession, suspended: Boolean) = emit()
override fun onSessionResumeFailed(session: CastSession, error: Int) = emit()
override fun onSessionSuspended(session: CastSession, reason: Int) = emit()
}
@Suppress("DEPRECATION")
private fun initialize(): CastContext {
check(!disposed)
return context ?: CastContext.getSharedInstance(activity).also {
context = it
it.sessionManager.addSessionManagerListener(listener, CastSession::class.java)
handler.post(poll)
}
}
fun state(): Map<String, Any> {
val session = context?.sessionManager?.currentCastSession
val connected = session?.isConnected == true
val client = if (connected) session?.remoteMediaClient else null
return mapOf(
"connected" to connected,
"playing" to (client?.isPlaying == true),
"positionMs" to (client?.approximateStreamPosition ?: 0L),
"durationMs" to (client?.streamDuration ?: 0L),
"deviceName" to (session?.castDevice?.friendlyName ?: "")
)
}
private fun emit() { if (!disposed) channel.invokeMethod("castChanged", state()) }
fun showPicker(): Boolean {
initialize()
activity.startActivity(Intent(activity, CastPickerActivity::class.java))
return true
}
fun getState(): Map<String, Any> { initialize(); return state() }
private fun remote(): RemoteMediaClient = initialize().sessionManager.currentCastSession
?.takeIf { it.isConnected }?.remoteMediaClient
?: throw IllegalStateException("No Cast receiver is connected.")
fun load(args: Map<*, *>, result: MethodChannel.Result) {
val url = args["url"] as? String ?: ""
require(NativeUrlPolicy.allowsRemote(url)) { "Unsupported Cast URL." }
val subtitle = (args["subtitleUrl"] as? String)?.takeIf { it.isNotBlank() }
require(subtitle == null || NativeUrlPolicy.allowsRemote(subtitle)) { "Unsupported subtitle URL." }
val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE).apply {
putString(MediaMetadata.KEY_TITLE, (args["title"] as? String).orEmpty().take(512))
}
val contentType = (args["contentType"] as? String)?.takeIf { it.isNotBlank() } ?: when {
android.net.Uri.parse(url).path.orEmpty().endsWith(".m3u8", true) -> "application/x-mpegURL"
android.net.Uri.parse(url).path.orEmpty().endsWith(".mpd", true) -> "application/dash+xml"
else -> "video/mp4"
}
val info = MediaInfo.Builder(url).setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setContentType(contentType).setMetadata(metadata)
if (subtitle != null) info.setMediaTracks(listOf(MediaTrack.Builder(1, MediaTrack.TYPE_TEXT)
.setContentId(subtitle).setContentType("text/vtt").setSubtype(MediaTrack.SUBTYPE_SUBTITLES)
.setName("Subtitles").build()))
val request = MediaLoadRequestData.Builder().setMediaInfo(info.build()).setAutoplay(true)
.setCurrentTime((args["positionMs"] as? Number)?.toLong()?.coerceAtLeast(0) ?: 0)
if (subtitle != null) request.setActiveTrackIds(longArrayOf(1))
complete(remote().load(request.build()), result)
}
fun command(args: Map<*, *>, result: MethodChannel.Result) {
val client = remote()
val request = when (args["action"] as? String) {
"play" -> client.play()
"pause" -> client.pause()
"stop" -> client.stop()
"seek" -> client.seek(MediaSeekOptions.Builder()
.setPosition((args["positionMs"] as? Number)?.toLong()?.coerceAtLeast(0) ?: 0).build())
"volume" -> client.setStreamVolume((args["value"] as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 1.0) ?: 1.0)
else -> throw IllegalArgumentException("Unknown Cast command.")
}
complete(request, result)
}
private fun complete(request: PendingResult<RemoteMediaClient.MediaChannelResult>, result: MethodChannel.Result) {
request.setResultCallback({ response ->
if (response.status.isSuccess) result.success(true)
else result.error("CAST_RECEIVER", "The Cast receiver rejected or timed out this operation.", response.status.statusCode)
emit()
}, 20, TimeUnit.SECONDS)
}
fun disconnect() { initialize().sessionManager.endCurrentSession(true); emit() }
fun dispose() {
disposed = true
handler.removeCallbacksAndMessages(null)
context?.sessionManager?.removeSessionManagerListener(listener, CastSession::class.java)
context = null
}
}
@@ -0,0 +1,116 @@
package org.reelbox.reelbox
import java.io.ByteArrayInputStream
import java.io.FilterInputStream
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URI
/** Credential-free HTTP transport: the WebView must never own HTTP redirects. */
internal class RestrictedResourceFetcher(
private val openConnection: (java.net.URL) -> HttpURLConnection = { it.openConnection() as HttpURLConnection },
) {
data class Response(
val statusCode: Int,
val body: InputStream,
val mimeType: String = "text/plain",
val encoding: String? = "UTF-8",
val reasonPhrase: String = "Blocked",
val headers: Map<String, String> = emptyMap(),
val effectiveUrl: String = "",
)
fun fetch(
url: String,
method: String = "GET",
requestHeaders: Map<String, String> = emptyMap(),
secureContext: Boolean = false,
): Response {
if (method != "GET" && method != "HEAD") return empty(405)
if (!allowsResource(url)) return empty(403)
var target = URI(url).normalize()
var requireTls = secureContext || target.scheme.equals("https", true)
var forwardContext = true
var active: HttpURLConnection? = null
try {
repeat(6) { hop ->
if (!allowsResource(target.toString()) ||
(requireTls && !target.scheme.equals("https", true))) return empty(403)
requireTls = requireTls || target.scheme.equals("https", true)
val connection = openConnection(target.toURL())
active = connection
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
connection.allowUserInteraction = false
connection.instanceFollowRedirects = false
connection.requestMethod = method
connection.useCaches = false
// A small public-resource allowlist, not arbitrary headers or a cookie jar.
for ((name, value) in requestHeaders) {
val lower = name.lowercase(java.util.Locale.ROOT)
val allowedHeader = lower in setOf("accept", "accept-language", "user-agent", "range", "if-range") ||
(forwardContext && lower in setOf("origin", "referer"))
if (allowedHeader && value.none { it == '\r' || it == '\n' || it == '\u0000' }) {
connection.setRequestProperty(name, value)
}
}
// Keep payload bytes and Content-Length/Range/Encoding consistent; no transparent gzip.
connection.setRequestProperty("Accept-Encoding", "identity")
val status = connection.responseCode
if (status in listOf(301, 302, 303, 307, 308)) {
val location = connection.getHeaderField("Location")
connection.disconnect()
active = null
if (location.isNullOrBlank()) return empty(502)
if (hop == 5) return empty(508)
val next = target.resolve(location).normalize()
fun port(uri: URI) = if (uri.port != -1) uri.port else if (uri.scheme.equals("https", true)) 443 else 80
if (!target.scheme.equals(next.scheme, true) || !target.host.equals(next.host, true) || port(target) != port(next)) {
forwardContext = false // Never restore a stripped context on a later hop back.
}
target = next
} else {
// Android rejects all 3xx WebResourceResponse statuses, including 304.
if (status !in 100..299 && status !in 400..599) return empty(502)
val stream = if (method == "HEAD") null else if (status >= 400) connection.errorStream else connection.inputStream
val contentType = connection.contentType.orEmpty().split(';')
val mime = contentType.first().trim().ifEmpty { "application/octet-stream" }
val charset = contentType.drop(1).firstOrNull { it.trim().startsWith("charset=", true) }
?.substringAfter('=')?.trim()?.trim('"', '\'')
val hopHeaders = connection.getHeaderField("Connection").orEmpty().split(',')
.map { it.trim().lowercase(java.util.Locale.ROOT) }.toSet()
val excluded = hopHeaders + setOf("connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "transfer-encoding", "upgrade", "set-cookie", "set-cookie2")
val headers = connection.headerFields.filterKeys { it != null && it.lowercase(java.util.Locale.ROOT) !in excluded }
.mapValues { (_, values) -> values.joinToString(", ") }
val body = object : FilterInputStream(stream ?: ByteArrayInputStream(ByteArray(0))) {
override fun close() { try { super.close() } finally { connection.disconnect() } }
}
val reason = connection.responseMessage.orEmpty()
.takeIf { it.isNotBlank() && it.all { char -> char.code in 32..126 } } ?: "HTTP response"
active = null // Body owns the connection until WebView closes it.
return Response(status, body, mime, charset, reason, headers, target.toString())
}
}
} catch (_: Exception) {
return empty(502)
} finally { active?.disconnect() }
return empty(508)
}
private fun allowsResource(url: String): Boolean = try {
if (!NativeUrlPolicy.allowsRemote(url)) false else {
val uri = URI(url)
var route = uri.rawPath.orEmpty() + "?" + uri.rawQuery.orEmpty()
repeat(3) { route = java.net.URLDecoder.decode(route, "UTF-8") }
route = java.text.Normalizer.normalize(route, java.text.Normalizer.Form.NFKC)
.lowercase(java.util.Locale.ROOT).replace(Regex("[\\p{Cf}]"), "")
// Removed catalog families are not embed resources. Keep private/episode playlists.
!Regex("(^|[/_.?&=\\-])(live|channels?|live[-_]?channels?|public[-_]?(play)?lists?)([/_.?&=\\-]|$)")
.containsMatchIn(route) &&
!Regex("[?&]action=(showlist|show-list_fav|playlist-items)([&#]|$)").containsMatchIn(route)
}
} catch (_: Exception) { false }
private fun empty(status: Int) = Response(status, ByteArrayInputStream(ByteArray(0)))
}
@@ -0,0 +1,643 @@
package org.reelbox.reelbox
import android.app.Activity
import android.app.Dialog
import android.app.PictureInPictureParams
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Rational
import android.view.View
import android.view.WindowInsets
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.Toast
import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.util.VLCVideoLayout
import java.io.File
import java.util.concurrent.Executors
/** Embedded LGPL LibVLC; never launches proprietary KMPlayer or rewrites source
* hosts. APIs verified against libvlc-all 3.7.7 sources and classes.jar on Maven
* Central. LibVLC owns asynchronous network I/O and normal TLS/auth validation.
* No network fetch, trust-manager override or media proxy exists in this view.
*
* Background policy is deliberately pause, not an unowned background decoder.
* PiP retains the video/decoder; closing the view releases it on a worker.
*/
@Suppress("DEPRECATION") // API 24 compatibility branches alongside modern APIs.
class VlcPlayerActivity : Activity() {
private val main = Handler(Looper.getMainLooper())
private val teardown = Executors.newSingleThreadExecutor()
private var vlc: LibVLC? = null
private var player: MediaPlayer? = null
private lateinit var launch: NativePlayerLaunch
private lateinit var surface: VLCVideoLayout
private lateinit var root: FrameLayout
private lateinit var overlay: PlayerOverlayView
private var requestId = ""
private var positionMs = 0L
private var durationMs = 0L
private var opened = false
private var completed = false
private var desiredPlaying = true
private var foreground = false
private var closed = false
private var loading = false
private var releasing = false
private var reloadRequested = false
private var error = false
private var pendingSeek: Long? = null
private var seekWaitTicks = 0
private var speed = 1f
private var fit = "contain"
private var hardware = true
private var subtitles = true
private var audioTrackId: Int? = null
private var subtitleTrackId: Int? = null
private var originalSystemUi = 0
private var originalOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private lateinit var audio: AudioManager
private var focusRequest: AudioFocusRequest? = null
private var hasFocus = false
private var resumeAfterFocus = false
private var receiverRegistered = false
private var dialog: Dialog? = null
private val menuSession = PlayerMenuSession()
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
main.post {
if (!closed) when (change) {
AudioManager.AUDIOFOCUS_GAIN -> {
hasFocus = true
player?.setVolume(100)
if (resumeAfterFocus && (foreground || inPip()) && !error) {
resumeAfterFocus = false
desiredPlaying = true
player?.play()
}
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> player?.setVolume(25)
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, AudioManager.AUDIOFOCUS_LOSS -> {
resumeAfterFocus = change == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT && desiredPlaying
hasFocus = false
if (change == AudioManager.AUDIOFOCUS_LOSS) desiredPlaying = false
player?.pause()
render()
}
}
}
}
private val noisy = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) pauseByUser()
}
}
private val tick = object : Runnable {
override fun run() {
if (closed) return
if (foreground || inPip()) {
applyPendingSeek()
snapshot()
render()
}
main.postDelayed(this, 500)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
originalSystemUi = window.decorView.systemUiVisibility
originalOrientation = requestedOrientation
requestId = intent.getStringExtra(NativePlayerRequests.REQUEST_ID).orEmpty()
// Even a failed/recreated launch has an identified cancellation result.
setResult(RESULT_CANCELED, Intent().putExtra(NativePlayerRequests.REQUEST_ID, requestId))
try {
val extras = intent.extras
val args = extras?.keySet()?.associateWith { extras.get(it) }.orEmpty()
launch = NativePlayerLaunch.from(args, ownedRoots(this))
} catch (_: Exception) {
finish()
return
}
positionMs = savedInstanceState?.getLong("positionMs") ?: launch.positionMs
durationMs = savedInstanceState?.getLong("durationMs") ?: 0
opened = savedInstanceState?.getBoolean("opened") ?: false
desiredPlaying = savedInstanceState?.getBoolean("playing") ?: launch.autoplay
speed = savedInstanceState?.getFloat("speed") ?: launch.speed
fit = savedInstanceState?.getString("fit") ?: launch.videoFit
hardware = savedInstanceState?.getBoolean("hardware") ?: launch.hardwareAcceleration
subtitles = savedInstanceState?.getBoolean("subtitles") ?: launch.subtitleEnabled
if (savedInstanceState?.containsKey("audioTrack") == true) audioTrackId = savedInstanceState.getInt("audioTrack")
if (savedInstanceState?.containsKey("subtitleTrack") == true) subtitleTrackId = savedInstanceState.getInt("subtitleTrack")
audio = getSystemService(AUDIO_SERVICE) as AudioManager
volumeControlStream = AudioManager.STREAM_MUSIC
if (Build.VERSION.SDK_INT >= 26) {
focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build())
.setOnAudioFocusChangeListener(focusListener, main).build()
}
if (Build.VERSION.SDK_INT >= 33) registerReceiver(noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY), RECEIVER_NOT_EXPORTED)
else registerReceiver(noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY))
receiverRegistered = true
requestedOrientation = when (launch.orientation) {
"landscape" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
"portrait" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
buildUi()
configureWindow()
publish()
main.post(tick)
load()
}
private fun buildUi() {
root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
surface = VLCVideoLayout(this)
root.addView(surface, FrameLayout.LayoutParams(-1, -1))
overlay = PlayerOverlayView(this, object : PlayerOverlayView.Callbacks {
override fun onClose() { finish() }
override fun onTogglePlayback() { togglePlay() }
override fun onSeekRelative(deltaMs: Long) { seekTo(PlayerSeekMath.relative(positionMs, deltaMs)) }
override fun onSeekTo(positionMs: Long) { seekTo(positionMs) }
override fun onSettings() { showSettings() }
override fun onExternal() { external() }
override fun onRetry(software: Boolean) { retry(software) }
override fun onPictureInPicture() {
if (!enterPip()) toast("Picture-in-picture is unavailable")
}
})
root.addView(overlay, FrameLayout.LayoutParams(-1, -1))
setContentView(root)
root.setOnApplyWindowInsetsListener { _, insets ->
// Insets never pad the video. Only the sibling chrome avoids bars.
if (inPip()) overlay.setSafeInsets(0, 0, 0, 0)
else if (Build.VERSION.SDK_INT >= 30) {
val bars = insets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout())
overlay.setSafeInsets(bars.left, bars.top, bars.right, bars.bottom)
} else overlay.setSafeInsets(insets.systemWindowInsetLeft, insets.systemWindowInsetTop,
insets.systemWindowInsetRight, insets.systemWindowInsetBottom)
insets
}
render()
}
@Suppress("DEPRECATION")
private fun configureWindow() {
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
if (Build.VERSION.SDK_INT >= 28) window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
root.requestApplyInsets()
}
private fun load() {
if (closed || !::launch.isInitialized) return
if (releasing) { reloadRequested = true; return }
error = false
loading = true
completed = false
pendingSeek = positionMs.takeIf { it > 0 }
seekWaitTicks = 0
render()
try {
val engine = LibVLC(applicationContext)
vlc = engine
val current = MediaPlayer(engine)
player = current
current.attachViews(surface, null, true, false)
applyFit()
current.setVideoTitleDisplay(MediaPlayer.Position.Disable, 0)
current.setEventListener { event ->
if (!closed && player === current) handleEvent(current, event)
}
val media = Media(engine, mediaUri(launch.url))
try {
media.setHWDecoderEnabled(hardware, false)
launch.subtitleUrl?.let { media.addSlave(IMedia.Slave(IMedia.Slave.Type.Subtitle, 4, mediaUri(it).toString())) }
current.media = media
} finally { media.release() }
current.rate = speed
// A paused launch still prerolls to obtain duration/tracks. Mute until
// its first Playing callback has paused it, avoiding an audio burst.
if (!desiredPlaying || !foreground) current.setVolume(0)
if (desiredPlaying && foreground && !requestFocus()) desiredPlaying = false
current.play()
render()
} catch (_: Exception) { fail() }
catch (_: LinkageError) { fail() }
}
private fun handleEvent(current: MediaPlayer, event: MediaPlayer.Event) {
if (error) {
if (event.type == MediaPlayer.Event.Playing) current.pause()
return // Errors remain visible until an explicit retry creates a new engine.
}
when (event.type) {
MediaPlayer.Event.Opening -> { loading = true }
MediaPlayer.Event.Buffering -> {
loading = event.buffering < 100f
}
MediaPlayer.Event.Playing -> {
opened = true
loading = false
error = false
applyPendingSeek()
current.rate = speed
restoreTracks()
if (!desiredPlaying || (!foreground && !inPip()) || !hasFocus) current.pause()
current.setVolume(100)
snapshot()
}
MediaPlayer.Event.Paused -> { loading = false; snapshot() }
MediaPlayer.Event.TimeChanged -> {
if (pendingSeek == null) positionMs = event.timeChanged.coerceAtLeast(0)
publish()
}
// Length can temporarily collapse during buffering/decoder teardown.
// This activity owns one source, so retain its longest known length.
MediaPlayer.Event.LengthChanged -> { durationMs = maxOf(durationMs, event.lengthChanged); publish() }
MediaPlayer.Event.SeekableChanged -> applyPendingSeek()
MediaPlayer.Event.ESAdded -> restoreTracks()
MediaPlayer.Event.EndReached -> {
// A truncated/ended input is not proof that the episode finished.
// Validate the last observed checkpoint BEFORE replacing it with
// the duration; otherwise an early EOF fabricates 100% + auto-next.
val tolerance = (durationMs / 50L).coerceIn(1L, 2000L)
val atEnd = opened && pendingSeek == null && durationMs > 0 &&
positionMs > 0 && positionMs <= durationMs && durationMs - positionMs <= tolerance
if (!atEnd) {
completed = false
desiredPlaying = false
// Stopped decoders may reset their time/length. Preserve the
// last observed checkpoint for explicit retry or handoff.
fail(captureSnapshot = false)
publish()
return
}
completed = true
desiredPlaying = false
if (durationMs > 0) positionMs = durationMs
publish()
finish()
return
}
// LibVLC exposes no reliable network-vs-decoder reason here. Never
// auto-retry this event: software retry requires explicit user choice.
MediaPlayer.Event.EncounteredError -> fail()
}
render()
}
private fun applyPendingSeek() {
val value = pendingSeek ?: return
val current = player ?: return
if (current.isSeekable) {
val duration = maxOf(0L, durationMs, current.length)
positionMs = if (duration > 0) value.coerceAtMost(duration) else value
pendingSeek = null
current.setTime(positionMs)
} else if (opened && ++seekWaitTicks > 12) {
pendingSeek = null
toast("This stream does not support seeking")
}
}
private fun snapshot() {
val current = player ?: return
if (!error) {
if (pendingSeek == null && !completed) positionMs = current.time.takeIf { it >= 0 } ?: positionMs
durationMs = maxOf(durationMs, current.length)
}
publish()
}
// An overrun makes the length uncertain, not the measured checkpoint.
// Keep canonical timing intact for EOF validation and later length metadata.
private fun credibleDuration(): Long = durationMs.coerceAtLeast(0).takeIf { it >= positionMs } ?: 0L
private fun publish() {
val duration = credibleDuration()
val position = positionMs.coerceAtLeast(0)
NativePlayerRequests.results.update(requestId, position, duration, opened, completed)
setResult(RESULT_OK, Intent().putExtra(NativePlayerRequests.REQUEST_ID, requestId)
.putExtra("positionMs", position).putExtra("durationMs", duration)
.putExtra("opened", opened).putExtra("completed", completed && opened))
}
private fun render() {
if (!::overlay.isInitialized || closed) return
val current = player
overlay.render(PlayerOverlayState(
title = launch.title, positionMs = positionMs.coerceAtLeast(0), durationMs = credibleDuration(),
playing = current?.isPlaying == true, loading = loading, error = error,
seekable = current?.isSeekable == true, speed = speed, pip = inPip(),
))
if (current?.isPlaying == true && (foreground || inPip())) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
private fun togglePlay() {
val current = player ?: return
if (current.isPlaying || desiredPlaying && loading) pauseByUser()
else if (requestFocus()) {
resumeAfterFocus = false
desiredPlaying = true
current.play()
} else toast("Audio is in use by another app")
render()
}
private fun pauseByUser() {
desiredPlaying = false
resumeAfterFocus = false
player?.pause()
snapshot()
render()
}
private fun seekTo(value: Long) {
val current = player ?: return
if (!current.isSeekable || error) { toast("This stream is not seekable"); return }
positionMs = value.coerceAtLeast(0).let { if (durationMs > 0) it.coerceAtMost(durationMs) else it }
pendingSeek = null
current.setTime(positionMs)
publish()
render()
}
private fun fail(captureSnapshot: Boolean = true) {
if (captureSnapshot) snapshot()
error = true
loading = false
player?.pause()
dialog?.dismiss()
render()
}
private fun retry(software: Boolean) {
if (closed || releasing) return
snapshot()
if (software) hardware = false
desiredPlaying = true
if (player == null && vlc == null) load()
else { reloadRequested = true; releaseEngine() }
}
private fun external() {
snapshot()
pauseByUser()
try {
val ok = NativeFileIntents(this).openExternal(mapOf(
"url" to launch.url, "title" to launch.title, "positionMs" to positionMs,
"packageName" to launch.externalPackage,
"subtitleUrl" to launch.subtitleUrl.takeIf { subtitles }))
if (!ok) toast("No external video player is installed")
} catch (_: Exception) { toast("This source could not be handed to an external player") }
}
private fun restoreTracks() {
val current = player ?: return
audioTrackId?.let { id -> if (current.audioTracks?.any { it.id == id } == true) current.setAudioTrack(id) }
if (!subtitles) current.setSpuTrack(-1)
else subtitleTrackId?.let { id -> if (current.spuTracks?.any { it.id == id } == true) current.setSpuTrack(id) }
}
private fun chooseTrack(subtitle: Boolean) {
val current = player ?: return
val tracks = (if (subtitle) current.spuTracks else current.audioTracks).orEmpty().filter { it.id >= 0 }
val ids = if (subtitle) listOf(-1) + tracks.map { it.id } else tracks.map { it.id }
val names = if (subtitle) listOf("Off") + tracks.map { it.name } else tracks.map { it.name }
if (ids.isEmpty()) { toast("No audio tracks available yet"); return }
val selected = if (subtitle) { if (subtitles) current.spuTrack else -1 } else current.audioTrack
showChoices(if (subtitle) "Subtitles" else "Audio track", names, ids.indexOf(selected)) { index ->
if (player !== current) return@showChoices
val id = ids[index]
val accepted = if (subtitle) current.setSpuTrack(id) else current.setAudioTrack(id)
if (accepted) {
if (subtitle) { subtitles = id != -1; subtitleTrackId = id }
else audioTrackId = id
} else toast("This track is not available")
}
}
private fun chooseFit() {
val values = listOf("contain", "cover", "fill")
showChoices("Video fit", listOf("Fit (letterbox)", "Cover (crop)", "Stretch"), values.indexOf(fit)) {
fit = values[it]; applyFit()
}
}
private fun applyFit() {
player?.setVideoScale(when (fit) {
"cover" -> MediaPlayer.ScaleType.SURFACE_FIT_SCREEN
"fill" -> MediaPlayer.ScaleType.SURFACE_FILL
else -> MediaPlayer.ScaleType.SURFACE_BEST_FIT
})
}
private fun chooseSpeed() {
val rates = listOf(0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, 2.5f, 3f)
showChoices("Playback speed", rates.map { "${it}×" }, rates.indexOf(speed)) {
speed = rates[it]; player?.rate = speed; render()
}
}
private fun showSettings() {
showChoices("Player settings", listOf("Audio track", "Subtitles", "Video fit", "Playback speed"), -1) {
when (it) {
0 -> chooseTrack(false)
1 -> chooseTrack(true)
2 -> chooseFit()
3 -> chooseSpeed()
}
}
}
private fun showChoices(title: String, values: List<String>, selected: Int, pick: (Int) -> Unit) {
if (closed || inPip()) return
// Invalidate the old owner before dismiss(): Android delivers onDismiss
// asynchronously, potentially after the replacement sheet has opened.
val owner = menuSession.open()
dialog?.dismiss()
overlay.setMenuOpen(true)
dialog = playerChoiceSheet(this, title, values, selected,
dismissed = {
if (menuSession.close(owner)) {
dialog = null
overlay.setMenuOpen(false)
}
},
pick = { index -> if (!closed && !inPip()) pick(index) })
}
private fun requestFocus(): Boolean {
if (hasFocus) return true
hasFocus = if (Build.VERSION.SDK_INT >= 26) audio.requestAudioFocus(focusRequest!!) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
else audio.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
return hasFocus
}
private fun abandonFocus() {
if (!::audio.isInitialized) return
if (Build.VERSION.SDK_INT >= 26) focusRequest?.let { audio.abandonAudioFocusRequest(it) }
else audio.abandonAudioFocus(focusListener)
hasFocus = false
}
private fun inPip() = Build.VERSION.SDK_INT >= 26 && isInPictureInPictureMode
private fun enterPip(): Boolean {
if (!::launch.isInitialized || !launch.pipEnabled || !opened || error || Build.VERSION.SDK_INT < 26 ||
!packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) return false
dialog?.dismiss()
return try {
enterPictureInPictureMode(PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).build())
} catch (_: RuntimeException) { false }
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
if (desiredPlaying && player?.isPlaying == true) enterPip()
}
override fun onPictureInPictureModeChanged(inPip: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(inPip, newConfig)
if (!::overlay.isInitialized) return
if (inPip) dialog?.dismiss()
render()
root.requestApplyInsets()
player?.updateVideoSurfaces()
if (!inPip && !foreground) player?.pause()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
if (::root.isInitialized) { configureWindow(); player?.updateVideoSurfaces() }
}
override fun onStart() {
super.onStart()
foreground = true
if (::overlay.isInitialized) overlay.setForeground(true)
if (!closed && ::launch.isInitialized && !error && desiredPlaying && requestFocus()) player?.play()
}
override fun onStop() {
foreground = false
if (::overlay.isInitialized) overlay.setForeground(false)
if (!inPip()) {
// Do not change desiredPlaying: returning restores the user's state.
player?.pause()
snapshot()
abandonFocus()
}
super.onStop()
}
override fun onSaveInstanceState(outState: Bundle) {
snapshot()
outState.putLong("positionMs", positionMs)
outState.putLong("durationMs", durationMs)
outState.putBoolean("opened", opened)
outState.putBoolean("playing", desiredPlaying)
outState.putFloat("speed", speed)
outState.putString("fit", fit)
outState.putBoolean("hardware", hardware)
outState.putBoolean("subtitles", subtitles)
audioTrackId?.let { outState.putInt("audioTrack", it) }
subtitleTrackId?.let { outState.putInt("subtitleTrack", it) }
super.onSaveInstanceState(outState)
}
override fun finish() {
if (!closed) {
snapshot()
publish()
closed = true
desiredPlaying = false
main.removeCallbacks(tick)
dialog?.dismiss()
if (::overlay.isInitialized) overlay.release()
abandonFocus()
releaseEngine()
}
super.finish()
}
/** View detachment must run on main; native stop/release can wait for network
* shutdown and are serialized off-main. No stale callbacks can touch a retry. */
private fun releaseEngine() {
val old = player
val engine = vlc
player = null
vlc = null
if (old == null && engine == null) return
old?.setEventListener(null)
old?.pause() // Silence immediately; native stop may wait for network I/O.
old?.detachViews()
releasing = true
teardown.execute {
try { old?.stop() } catch (_: Exception) { }
try { old?.release() } catch (_: Exception) { }
try { engine?.release() } catch (_: Exception) { }
main.post {
releasing = false
if (!closed && reloadRequested) { reloadRequested = false; load() }
}
}
}
override fun onDestroy() {
snapshot()
closed = true
main.removeCallbacksAndMessages(null)
dialog?.dismiss()
if (::overlay.isInitialized) overlay.release()
if (receiverRegistered) { unregisterReceiver(noisy); receiverRegistered = false }
abandonFocus()
releaseEngine()
teardown.shutdown()
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.decorView.systemUiVisibility = originalSystemUi
// Orientation is activity-scoped, never mutate the Flutter host's window.
if (requestedOrientation != originalOrientation) requestedOrientation = originalOrientation
super.onDestroy()
}
private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
companion object {
fun ownedRoots(context: Context): List<File> = listOfNotNull(context.filesDir, context.cacheDir,
context.getExternalFilesDir(null), context.externalCacheDir)
private fun mediaUri(value: String): Uri = if (value.startsWith('/')) Uri.fromFile(File(value)) else Uri.parse(value)
fun intent(context: Context, value: NativePlayerLaunch, id: String): Intent = Intent(context, VlcPlayerActivity::class.java)
.putExtra(NativePlayerRequests.REQUEST_ID, id)
.putExtra("url", value.url).putExtra("title", value.title).putExtra("positionMs", value.positionMs)
.putExtra("autoplay", value.autoplay).putExtra("externalPackage", value.externalPackage)
.putExtra("orientation", value.orientation).putExtra("videoFit", value.videoFit).putExtra("speed", value.speed)
.putExtra("subtitleEnabled", value.subtitleEnabled).putExtra("subtitleUrl", value.subtitleUrl)
.putExtra("hardwareAcceleration", value.hardwareAcceleration).putExtra("pipEnabled", value.pipEnabled)
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M8,5v14l11,-7z" />
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="ReelboxCastTheme" parent="Theme.AppCompat.DayNight.DarkActionBar">
<item name="colorAccent">#55DCC0</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="app_files" path="." />
<cache-path name="app_cache" path="." />
<external-files-path name="external_files" path="." />
<external-cache-path name="external_cache" path="." />
</paths>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,183 @@
package org.reelbox.reelbox;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import kotlin.Unit;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.GraphicsMode;
import org.robolectric.annotation.LooperMode;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.shadow.api.Shadow;
import org.videolan.libvlc.MediaPlayer;
import static org.junit.Assert.*;
/** Drives the actual activity event handler and result gate, without a decoder.
* Event/checkpoint fixtures are explicit; this is not device/network playback.
*/
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 28, manifest = Config.NONE)
@LooperMode(LooperMode.Mode.PAUSED)
@GraphicsMode(GraphicsMode.Mode.LEGACY)
public class NativePlayerTimingAndroidTest {
private static Field field(String name) throws Exception {
Field field = VlcPlayerActivity.class.getDeclaredField(name);
field.setAccessible(true);
return field;
}
private static void set(VlcPlayerActivity activity, String name, Object value) throws Exception {
field(name).set(activity, value);
}
private static Object get(VlcPlayerActivity activity, String name) throws Exception {
return field(name).get(activity);
}
private static void event(VlcPlayerActivity activity, int type, long value) throws Exception {
Constructor<MediaPlayer.Event> constructor = MediaPlayer.Event.class.getDeclaredConstructor(int.class, long.class);
constructor.setAccessible(true);
MediaPlayer.Event event = constructor.newInstance(type, value);
Method handler = VlcPlayerActivity.class.getDeclaredMethod("handleEvent", MediaPlayer.class, MediaPlayer.Event.class);
handler.setAccessible(true);
// These timing/EOF branches use activity state, not a decoder instance.
// Deliberately do not start LibVLC/JNI or invent a decoded frame.
handler.invoke(activity, null, event);
}
private static VlcPlayerActivity checkpoint(long position, long duration, boolean opened) throws Exception {
VlcPlayerActivity activity = Robolectric.buildActivity(VlcPlayerActivity.class).get();
set(activity, "positionMs", position);
set(activity, "durationMs", duration);
set(activity, "opened", opened);
return activity;
}
private static void close(VlcPlayerActivity activity) throws Exception {
activity.finish();
((ExecutorService) get(activity, "teardown")).shutdownNow();
NativePlayerRequests.INSTANCE.getResults().dispose();
}
@Implements(value = MediaPlayer.class, callThroughByDefault = false)
public static class SeekDecoder {
static long lastSeek;
@Implementation protected boolean isSeekable() { return true; }
@Implementation protected long getLength() { return 120_000L; }
@Implementation protected long setTime(long value) { lastSeek = value; return 0; }
}
@Test
@Config(shadows = SeekDecoder.class, instrumentedPackages = "org.videolan.libvlc")
public void retrySeekKeepsCheckpointWhenDecoderLengthTemporarilyShrinks() throws Exception {
VlcPlayerActivity activity = checkpoint(300_000L, 3_600_000L, true);
MediaPlayer decoder = Shadow.newInstanceOf(MediaPlayer.class);
set(activity, "player", decoder);
set(activity, "pendingSeek", 300_000L);
SeekDecoder.lastSeek = -1;
try {
Method seek = VlcPlayerActivity.class.getDeclaredMethod("applyPendingSeek");
seek.setAccessible(true);
seek.invoke(activity);
assertEquals("Retry must not resume at a shorter, transient length", 300_000L, SeekDecoder.lastSeek);
assertEquals(300_000L, get(activity, "positionMs"));
} finally {
set(activity, "player", null); // No decoder/JNI was constructed.
close(activity);
}
}
@Test public void overrunTimeSampleCannotBeClampedIntoSuccessfulCompletion() throws Exception {
VlcPlayerActivity activity = checkpoint(180_000L, 3_600_000L, true);
List<Map<String, ? extends Object>> results = new ArrayList<>();
NativePlayerResultGate gate = NativePlayerRequests.INSTANCE.getResults();
gate.dispose();
assertTrue(gate.begin("overrun", 0L, value -> { results.add(value); return Unit.INSTANCE; }));
set(activity, "requestId", "overrun");
try {
event(activity, MediaPlayer.Event.TimeChanged, 3_600_001L);
event(activity, MediaPlayer.Event.EndReached, 0);
assertFalse("A clamped overrun must not authorize auto-next", activity.isFinishing());
assertEquals(3_600_001L, get(activity, "positionMs"));
assertEquals(false, get(activity, "completed"));
gate.finish("overrun");
assertEquals(3_600_001L, results.get(0).get("positionMs"));
assertEquals("Inconsistent duration is unknown, not a fake 100%", 0L, results.get(0).get("durationMs"));
} finally { close(activity); }
}
@Test public void transientLengthChangesKeepKnownDurationAndActualCheckpoint() throws Exception {
for (long reported : new long[] {120_000L, 0L, -1L}) {
VlcPlayerActivity activity = checkpoint(300_000L, 3_600_000L, true);
try {
event(activity, MediaPlayer.Event.LengthChanged, reported);
assertEquals("Transient length must not replace known episode duration", 3_600_000L, get(activity, "durationMs"));
assertEquals("Length metadata must not move the playback checkpoint", 300_000L, get(activity, "positionMs"));
} finally { close(activity); }
}
}
@Test public void unknownDurationEndCannotClaimCompletion() throws Exception {
VlcPlayerActivity activity = checkpoint(180_000L, 0L, true);
try {
event(activity, MediaPlayer.Event.EndReached, 0);
assertFalse(activity.isFinishing());
assertEquals(false, get(activity, "completed"));
assertEquals(180_000L, get(activity, "positionMs"));
} finally { close(activity); }
}
@Test public void pendingResumeSeekCannotClaimCompletion() throws Exception {
VlcPlayerActivity activity = checkpoint(3_599_500L, 3_600_000L, true);
set(activity, "pendingSeek", 3_599_500L);
try {
event(activity, MediaPlayer.Event.EndReached, 0);
assertFalse(activity.isFinishing());
assertEquals(false, get(activity, "completed"));
} finally { close(activity); }
}
@Test public void neverOpenedDecoderCannotClaimCompletion() throws Exception {
VlcPlayerActivity activity = checkpoint(3_600_000L, 3_600_000L, false);
try {
event(activity, MediaPlayer.Event.EndReached, 0);
assertFalse(activity.isFinishing());
assertEquals(false, get(activity, "completed"));
} finally { close(activity); }
}
@Test public void realNearEndStillCompletesNormally() throws Exception {
VlcPlayerActivity activity = checkpoint(3_599_500L, 3_600_000L, true);
try {
event(activity, MediaPlayer.Event.EndReached, 0);
assertTrue(activity.isFinishing());
assertEquals(true, get(activity, "completed"));
assertEquals(3_600_000L, get(activity, "positionMs"));
} finally { close(activity); }
}
@Test public void prematureEndDoesNotFinishOrManufacture100Percent() throws Exception {
VlcPlayerActivity activity = checkpoint(180_000L, 3_600_000L, true);
List<Map<String, ? extends Object>> results = new ArrayList<>();
NativePlayerResultGate gate = NativePlayerRequests.INSTANCE.getResults();
gate.dispose();
assertTrue(gate.begin("early-eof", 0L, value -> { results.add(value); return Unit.INSTANCE; }));
set(activity, "requestId", "early-eof");
try {
event(activity, MediaPlayer.Event.EndReached, 0);
assertFalse("An early decoder EOF must not finish the activity and auto-next", activity.isFinishing());
assertEquals("Keep the actual three-minute checkpoint", 180_000L, get(activity, "positionMs"));
assertEquals(false, get(activity, "completed"));
assertEquals("Show recovery instead of silently skipping the episode", true, get(activity, "error"));
gate.finish("early-eof");
assertEquals(1, results.size());
assertEquals(180_000L, results.get(0).get("positionMs"));
assertEquals(3_600_000L, results.get(0).get("durationMs"));
assertEquals(false, results.get(0).get("completed"));
} finally { close(activity); }
}
}
@@ -0,0 +1,785 @@
package org.reelbox.reelbox;
import static org.junit.Assert.*;
import android.app.Activity;
import android.graphics.Rect;
import android.os.Looper;
import android.os.SystemClock;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.widget.SeekBar;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.android.controller.ActivityController;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.GraphicsMode;
import org.robolectric.annotation.LooperMode;
/**
* Host-side tests of the actual native view, not a policy model or source-string checks.
* PAUSED gives deterministic main Handler/gesture timing. LEGACY tests real Android view
* geometry and touch dispatch, NOT device pixels, native Skia, video decoding or PiP OS UI.
*/
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 28, manifest = Config.NONE, qualifiers = "w360dp-h780dp-mdpi")
@LooperMode(LooperMode.Mode.PAUSED)
@GraphicsMode(GraphicsMode.Mode.LEGACY)
public class PlayerOverlayAndroidTest {
private ActivityController<Activity> controller;
private PlayerOverlayView overlay;
private RecordingCallbacks callbacks;
private int width = 360;
private int height = 780;
private boolean released;
private static final class RecordingCallbacks implements PlayerOverlayView.Callbacks {
int closes, toggles, settings, external, pip;
final List<Long> relative = new ArrayList<>();
final List<Long> absolute = new ArrayList<>();
final List<Long> absoluteAt = new ArrayList<>();
final List<Boolean> retries = new ArrayList<>();
Runnable closeHook, relativeHook;
@Override public void onClose() { closes++; if (closeHook != null) closeHook.run(); }
@Override public void onTogglePlayback() { toggles++; }
@Override public void onSeekRelative(long deltaMs) {
relative.add(deltaMs);
if (relativeHook != null) relativeHook.run();
}
@Override public void onSeekTo(long positionMs) {
absoluteAt.add(SystemClock.uptimeMillis());
absolute.add(positionMs);
}
@Override public void onSettings() { settings++; }
@Override public void onExternal() { external++; }
@Override public void onRetry(boolean software) { retries.add(software); }
@Override public void onPictureInPicture() { pip++; }
int actionCount() {
return closes + toggles + settings + external + pip
+ relative.size() + absolute.size() + retries.size();
}
}
@Before public void createRealAttachedOverlay() {
controller = Robolectric.buildActivity(Activity.class);
controller.get().setTheme(android.R.style.Theme_Material_NoActionBar);
controller.setup().visible();
callbacks = new RecordingCallbacks();
overlay = new PlayerOverlayView(controller.get(), callbacks);
controller.get().setContentView(overlay);
overlay.setForeground(true);
overlay.render(state(true, false, false));
layout(360, 780);
tagged("player-video-gestures");
}
@After public void destroyRealAttachedOverlay() {
if (overlay != null && !released) overlay.release();
if (controller != null) controller.pause().stop().destroy();
}
private PlayerOverlayState state(boolean playing, boolean error, boolean pip) {
return stateAt(30_000L, playing, error, pip);
}
private PlayerOverlayState stateAt(long position, boolean playing, boolean error, boolean pip) {
return new PlayerOverlayState("A long title — عنوان فارسی برای آزمایش رابط ویدیو",
position, 120_000L, playing, false, error, true, 1.0f, pip);
}
private void layout(int w, int h) {
width = w; height = h;
overlay.measure(View.MeasureSpec.makeMeasureSpec(w, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(h, View.MeasureSpec.EXACTLY));
overlay.layout(0, 0, w, h);
}
private View tagged(String tag) {
View result = overlay.findViewWithTag(tag);
assertNotNull("Required real view tag missing: " + tag, result);
return result;
}
private boolean visible(View view) {
for (View node = view; node != null; ) {
if (node.getVisibility() != View.VISIBLE || node.getAlpha() <= 0f) return false;
if (node == overlay) return true;
node = node.getParent() instanceof View ? (View) node.getParent() : null;
}
return false;
}
private void assertChrome(boolean expected) {
assertEquals("Chrome visibility", expected, visible(tagged("player-controls")));
}
private PlayerControlsState controls() {
try {
java.lang.reflect.Field field = overlay.getClass().getDeclaredField("controls");
field.setAccessible(true);
return (PlayerControlsState) field.get(overlay);
} catch (ReflectiveOperationException error) { throw new AssertionError(error); }
}
private void idle(long milliseconds) {
Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(milliseconds));
}
private void idleUntil(long uptime) {
long remaining = uptime - SystemClock.uptimeMillis();
if (remaining > 0) idle(remaining);
}
private Rect bounds(View child) {
Rect rect = new Rect(0, 0, child.getWidth(), child.getHeight());
overlay.offsetDescendantRectToMyCoords(child, rect);
return rect;
}
private void dispatch(int action, float x, float y, long downTime) {
MotionEvent event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, x, y, 0);
try { overlay.dispatchTouchEvent(event); }
finally { event.recycle(); }
}
private void rawTap(float x, float y) {
long down = SystemClock.uptimeMillis();
dispatch(MotionEvent.ACTION_DOWN, x, y, down);
idle(16);
dispatch(MotionEvent.ACTION_UP, x, y, down);
// Android View posts PerformClick at the current uptime; drain it without
// advancing through GestureDetector's delayed single-tap confirmation.
Shadows.shadowOf(Looper.getMainLooper()).idle();
}
private void tapVideo() {
// Away from header, transport buttons, unlock button and bottom scrub bar.
rawTap(width * 0.17f, height * 0.27f);
idle(ViewConfiguration.getDoubleTapTimeout() + 40L);
}
private void doubleTapVideo(float fraction) {
rawTap(width * fraction, height * 0.32f);
idle(70);
rawTap(width * fraction, height * 0.32f);
idle(ViewConfiguration.getDoubleTapTimeout() + 40L);
}
private void tapControl(String tag) {
layout(width, height);
View target = tagged(tag);
assertTrue("Control is not visible: " + tag, visible(target));
assertTrue("Control disabled: " + tag, target.isEnabled());
Rect rect = bounds(target);
assertTrue("Control has no touch area: " + tag + " " + rect, rect.width() > 0 && rect.height() > 0);
rawTap(rect.exactCenterX(), rect.exactCenterY());
}
@Test public void videoSingleTapHidesAndShowsChrome() {
assertChrome(true);
tapVideo();
assertChrome(false);
tapVideo();
assertChrome(true);
}
@Test public void playingAutoHidesAtThreeSeconds() {
idle(2999);
assertChrome(true);
idle(2);
assertChrome(false);
}
@Test public void progressRendersDoNotExtendIdleDeadline() {
idle(1000);
overlay.render(stateAt(31_000L, true, false, false));
idle(1000);
overlay.render(stateAt(32_000L, true, false, false));
idle(1001);
assertChrome(false);
}
@Test public void progressRenderNeverReshowsHiddenChrome() {
tapVideo();
assertChrome(false);
overlay.render(stateAt(31_000L, true, false, false));
layout(width, height);
assertChrome(false);
idle(4000);
assertChrome(false);
}
@Test public void scrubHoldsTimerAndReleaseResumesIt() {
View seek = tagged("player-seek");
assertTrue("Real native SeekBar required", seek instanceof SeekBar);
idle(2000);
// Window traversal can change attached-view bounds. Resolve the live
// touch target AFTER idling, not from a stale pre-traversal rectangle.
Rect rect = bounds(seek);
float y = rect.exactCenterY();
float x = rect.left + rect.width() * 0.35f;
long down = SystemClock.uptimeMillis();
dispatch(MotionEvent.ACTION_DOWN, x, y, down);
dispatch(MotionEvent.ACTION_MOVE, x + rect.width() * 0.10f, y, down);
assertTrue("Real SeekBar must receive and track the touch", seek.isPressed());
assertTrue("Finger tracking must only preview until UP", callbacks.absolute.isEmpty());
idle(4500);
assertChrome(true);
long stoppedAt = SystemClock.uptimeMillis();
dispatch(MotionEvent.ACTION_UP, x + rect.width() * 0.10f, y, down);
assertEquals("Finger UP commits exactly once", 1, callbacks.absolute.size());
long position = callbacks.absolute.get(callbacks.absolute.size() - 1);
assertTrue("Seek target must be within duration: " + position, position >= 0 && position <= 120_000L);
Long deadline = controls().getDeadlineMs();
assertEquals("Production idle interval stays exactly 3000ms", Long.valueOf(callbacks.absoluteAt.get(0) + 3000), deadline);
// Android's pressed/ripple traversal advances Robolectric uptime during UP
// (observed +15ms). Relative idleFor(2999) AFTER UP crossed the deadline.
// Assert against the production monotonic deadline, without widening it.
System.out.println("SCRUB_CLOCK stoppedAt=" + stoppedAt + " afterUp=" + SystemClock.uptimeMillis()
+ " deadline=" + deadline);
idleUntil(deadline - 1);
assertChrome(true);
idleUntil(deadline);
assertChrome(false);
assertEquals(1, callbacks.absolute.size());
}
@Test public void errorCancelsActiveScrubWithoutCommittingAndRetryRecovers() {
assertCancelledScrubRecovers("error");
}
@Test public void seekabilityLossCancelsActiveScrubWithoutCommitting() {
assertCancelledScrubRecovers("unseekable");
}
@Test public void pipCancelsActiveScrubWithoutCommitting() {
assertCancelledScrubRecovers("pip");
}
@Test public void lockCancelsActiveScrubWithoutCommitting() {
assertCancelledScrubRecovers("lock");
}
@Test public void seekabilityRecoveryBeforeFingerUpCannotCommitCancelledScrub() {
assertRecoveryBeforeFingerUp("unseekable", false);
}
@Test public void errorRecoveryBeforeFingerUpCannotCommitCancelledScrub() {
assertRecoveryBeforeFingerUp("error", false);
}
@Test public void pipRecoveryBeforeFingerUpCannotCommitCancelledScrub() {
assertRecoveryBeforeFingerUp("pip", false);
}
@Test public void unlockBeforeFingerUpCannotCommitCancelledScrub() {
assertRecoveryBeforeFingerUp("lock", false);
}
@Test public void cancelledTouchSuppressionDoesNotBlockAccessibilitySeeking() {
assertRecoveryBeforeFingerUp("unseekable", true);
}
private void assertRecoveryBeforeFingerUp(String interruption, boolean accessibility) {
idle(1000);
SeekBar seek = (SeekBar) tagged("player-seek");
Rect rect = bounds(seek);
float x = rect.left + rect.width() * .35f;
float y = rect.exactCenterY();
long down = SystemClock.uptimeMillis();
dispatch(MotionEvent.ACTION_DOWN, x, y, down);
dispatch(MotionEvent.ACTION_MOVE, x + rect.width() * .15f, y, down);
assertTrue("Fixture must start actual native tracking", seek.isPressed());
assertTrue(callbacks.absolute.isEmpty());
if (interruption.equals("lock")) tagged("player-lock").performClick();
else overlay.render(new PlayerOverlayState("Interrupted", 45_000L, 120_000L,
true, false, interruption.equals("error"), !interruption.equals("unseekable"),
1f, interruption.equals("pip")));
assertFalse(seek.isPressed());
if (interruption.equals("lock")) tagged("player-unlock").performClick();
// Recover WHILE the original finger is still down: its parent touch
// target must not be allowed to restart native tracking on MOVE or UP.
overlay.render(stateAt(75_000L, true, false, false));
layout(width, height);
assertTrue(seek.isEnabled());
if (accessibility) {
assertTrue(seek.performAccessibilityAction(
android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD, null));
assertEquals("Non-touch seeking remains immediate", 1, callbacks.absolute.size());
callbacks.absolute.clear();
}
dispatch(MotionEvent.ACTION_MOVE, x + rect.width() * .25f, y, down);
assertFalse("Cancelled finger cannot restart native tracking after recovery", seek.isPressed());
dispatch(MotionEvent.ACTION_UP, x + rect.width() * .25f, y, down);
assertTrue("Cancelled finger cannot commit after recovery", callbacks.absolute.isEmpty());
assertFalse(seek.isPressed());
rect = bounds(seek);
long freshDown = SystemClock.uptimeMillis();
float freshX = rect.left + rect.width() * .3f;
dispatch(MotionEvent.ACTION_DOWN, freshX, rect.exactCenterY(), freshDown);
dispatch(MotionEvent.ACTION_MOVE, freshX + rect.width() * .2f, rect.exactCenterY(), freshDown);
assertTrue("A fresh finger stream must still start tracking", seek.isPressed());
dispatch(MotionEvent.ACTION_UP, freshX + rect.width() * .2f, rect.exactCenterY(), freshDown);
assertEquals("A fresh scrub commits exactly once", 1, callbacks.absolute.size());
}
private void assertCancelledScrubRecovers(String interruption) {
idle(2000);
SeekBar seek = (SeekBar) tagged("player-seek");
Rect rect = bounds(seek);
float x = rect.left + rect.width() * .35f;
float y = rect.exactCenterY();
long down = SystemClock.uptimeMillis();
dispatch(MotionEvent.ACTION_DOWN, x, y, down);
dispatch(MotionEvent.ACTION_MOVE, x + rect.width() * .2f, y, down);
assertTrue("Test requires real active SeekBar tracking", seek.isPressed());
assertTrue("Finger drag is preview only until release", callbacks.absolute.isEmpty());
if (interruption.equals("lock")) tagged("player-lock").performClick();
else overlay.render(new PlayerOverlayState("Interrupted", 45_000L, 120_000L,
true, false, interruption.equals("error"), !interruption.equals("unseekable"),
1f, interruption.equals("pip")));
assertFalse("Interruption must cancel pressed/tracking before disabling SeekBar", seek.isPressed());
dispatch(MotionEvent.ACTION_UP, x + rect.width() * .2f, y, down);
assertTrue("Cancelled scrub must never reach decoder callback", callbacks.absolute.isEmpty());
if (interruption.equals("error")) {
tagged("player-retry").performClick();
assertEquals(List.of(false), callbacks.retries);
}
if (interruption.equals("lock")) tagged("player-unlock").performClick();
overlay.render(stateAt(75_000L, true, false, false));
assertEquals("Recovery must resume real progress snapshots", 6250, seek.getProgress());
assertEquals("1:15", ((android.widget.TextView) tagged("player-clock")).getText().toString());
assertTrue(seek.isEnabled());
Long deadline = controls().getDeadlineMs();
assertNotNull("Cancelled seek cannot strand visibility hold", deadline);
idleUntil(deadline - 1);
assertChrome(true);
idleUntil(deadline);
assertChrome(false);
}
@Test public void nativeTouchCancelDoesNotCommitScrub() {
SeekBar seek = (SeekBar) tagged("player-seek");
Rect rect = bounds(seek);
long down = SystemClock.uptimeMillis();
dispatch(MotionEvent.ACTION_DOWN, rect.exactCenterX(), rect.exactCenterY(), down);
dispatch(MotionEvent.ACTION_MOVE, rect.right - 30, rect.exactCenterY(), down);
assertTrue(seek.isPressed());
dispatch(MotionEvent.ACTION_CANCEL, rect.right - 30, rect.exactCenterY(), down);
assertFalse(seek.isPressed());
assertTrue("ACTION_CANCEL is not a committed seek", callbacks.absolute.isEmpty());
assertNotNull(controls().getDeadlineMs());
}
@Test public void keyboardSeekCommitsImmediatelyAndRenewsVisibleDeadline() {
assertNonTouchSeekCommits(false);
}
@Test public void accessibilitySeekCommitsImmediatelyAndRenewsVisibleDeadline() {
assertNonTouchSeekCommits(true);
}
private void assertNonTouchSeekCommits(boolean accessibility) {
SeekBar seek = (SeekBar) tagged("player-seek");
long oldDeadline = controls().getDeadlineMs();
idle(2500);
int oldProgress = seek.getProgress();
long interactedAt = SystemClock.uptimeMillis();
if (accessibility) assertTrue(seek.performAccessibilityAction(
android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD, null));
else {
seek.requestFocus();
assertTrue(seek.dispatchKeyEvent(new android.view.KeyEvent(
android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_RIGHT)));
seek.dispatchKeyEvent(new android.view.KeyEvent(
android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_DPAD_RIGHT));
}
assertTrue("Real user action must move SeekBar", seek.getProgress() > oldProgress);
assertEquals("Non-touch change has no stop-tracking event: commit it immediately once",
List.of(120_000L * seek.getProgress() / 10000), callbacks.absolute);
Long deadline = controls().getDeadlineMs();
assertNotNull(deadline);
assertTrue("Non-touch seek must renew interaction deadline", deadline >= interactedAt + 3000);
idleUntil(oldDeadline + 1);
assertChrome(true);
idleUntil(deadline - 1);
assertChrome(true);
idleUntil(deadline);
assertChrome(false);
assertEquals("No deferred duplicate seek", 1, callbacks.absolute.size());
}
@Test public void maximumNativeDurationSeekCannotOverflow() {
long duration = Long.MAX_VALUE / 1000L; // Accepted native millisecond range.
overlay.render(new PlayerOverlayState("Long source", duration / 4, duration,
true, false, false, true, 1f, false));
SeekBar seek = (SeekBar) tagged("player-seek");
seek.setKeyProgressIncrement(10000);
seek.requestFocus();
assertTrue(seek.dispatchKeyEvent(new android.view.KeyEvent(
android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_RIGHT)));
assertEquals(10000, seek.getProgress());
assertEquals("End-of-timeline target must equal duration without intermediate overflow",
List.of(duration), callbacks.absolute);
}
@Test public void openMenuHoldsTimerAndDismissResumesIt() {
idle(2000);
tapControl("player-settings");
assertEquals("Settings dispatch is immediate", 1, callbacks.settings);
overlay.setMenuOpen(true);
idle(4500);
assertChrome(true);
overlay.render(stateAt(34_000L, true, false, false));
assertChrome(true);
overlay.setMenuOpen(false);
idle(2999);
assertChrome(true);
idle(2);
assertChrome(false);
}
@Test public void delayedOldDialogDismissCannotReleaseNestedSheetHold() {
PlayerMenuSession sessions = new PlayerMenuSession();
android.app.Dialog[] sheets = new android.app.Dialog[2];
int[] dismissals = {0};
Object oldOwner = sessions.open();
overlay.setMenuOpen(true);
sheets[0] = PlayerOverlayViewKt.playerChoiceSheet(controller.get(), "Settings",
List.of("Playback speed"), -1,
() -> {
dismissals[0]++;
if (sessions.close(oldOwner)) overlay.setMenuOpen(false);
return kotlin.Unit.INSTANCE;
},
index -> {
// Replacement invalidates the old owner BEFORE dismissing it.
Object newOwner = sessions.open();
sheets[0].dismiss();
overlay.setMenuOpen(true);
sheets[1] = PlayerOverlayViewKt.playerChoiceSheet(controller.get(), "Playback speed",
List.of("1×", "2×"), 0,
() -> {
if (sessions.close(newOwner)) overlay.setMenuOpen(false);
return kotlin.Unit.INSTANCE;
}, value -> kotlin.Unit.INSTANCE);
return kotlin.Unit.INSTANCE;
});
View row = findDescription(sheets[0].getWindow().getDecorView(), "Playback speed");
assertNotNull(row);
row.performClick();
assertNotNull("Actual choice must open a nested sheet", sheets[1]);
assertEquals("Old onDismiss must still be queued", 0, dismissals[0]);
idle(4000);
assertEquals(1, dismissals[0]);
assertTrue(sheets[1].isShowing());
assertChrome(true);
assertNull("Nested sheet must retain the auto-hide hold", controls().getDeadlineMs());
long dismissedAt = SystemClock.uptimeMillis();
sheets[1].dismiss();
idleUntil(dismissedAt + 2999);
assertChrome(true);
idleUntil(dismissedAt + 3001);
assertChrome(false);
}
private View findDescription(View root, String description) {
if (description.equals(String.valueOf(root.getContentDescription()))) return root;
if (root instanceof ViewGroup) {
ViewGroup group = (ViewGroup) root;
for (int i = 0; i < group.getChildCount(); i++) {
View result = findDescription(group.getChildAt(i), description);
if (result != null) return result;
}
}
return null;
}
@Test public void pausedSingleTapStillHidesChrome() {
overlay.render(state(false, false, false));
idle(5000);
assertChrome(true);
tapVideo();
assertChrome(false);
idle(5000);
assertChrome(false);
}
@Test public void playButtonDispatchesImmediatelyWithoutGestureDelay() {
tapControl("player-play");
assertEquals(1, callbacks.toggles);
assertChrome(true);
idle(ViewConfiguration.getDoubleTapTimeout() + 40L);
assertEquals("Button must not also bubble into video gesture", 1, callbacks.toggles);
assertChrome(true);
}
@Test public void leftDoubleTapSeeksBackWithoutSingleTapChromeToggle() {
doubleTapVideo(0.18f);
assertEquals(List.of(-10_000L), callbacks.relative);
assertEquals(0, callbacks.toggles);
assertChrome(true);
}
@Test public void rightDoubleTapSeeksForwardWithoutSingleTapChromeToggle() {
doubleTapVideo(0.82f);
assertEquals(List.of(10_000L), callbacks.relative);
assertEquals(0, callbacks.toggles);
assertChrome(true);
}
@Test public void lockedSuppressesChromeGesturesAndSeekingUntilUnlock() {
Rect play = bounds(tagged("player-play"));
Rect seek = bounds(tagged("player-seek"));
tapControl("player-lock");
assertTrue("Unlock affordance must remain visible", visible(tagged("player-unlock")));
assertChrome(false);
List<View> lockedTargets = new ArrayList<>();
collectClickable(overlay, lockedTargets);
lockedTargets.remove(tagged("player-video-gestures")); // Consuming surface, tested below.
assertEquals("Unlock must be the only active visible control", List.of(tagged("player-unlock")), lockedTargets);
int before = callbacks.actionCount();
tapVideo();
doubleTapVideo(0.18f);
doubleTapVideo(0.82f);
rawTap(play.exactCenterX(), play.exactCenterY());
rawTap(seek.left + seek.width() * 0.8f, seek.exactCenterY());
assertEquals("Locked controls or gestures dispatched a callback", before, callbacks.actionCount());
assertChrome(false);
assertTrue(visible(tagged("player-unlock")));
tapControl("player-unlock");
assertChrome(true);
assertFalse(visible(tagged("player-unlock")));
tapControl("player-play");
assertEquals(1, callbacks.toggles);
}
@Test public void pictureInPictureHidesAllOverlayUiAndSuppressesTaps() {
overlay.render(state(true, false, true));
layout(width, height);
assertChrome(false);
for (String tag : List.of("player-play", "player-back", "player-external", "player-settings",
"player-lock", "player-unlock", "player-seek", "player-loading", "player-error")) {
assertFalse("PiP leaked visible overlay: " + tag, visible(tagged(tag)));
}
List<View> pipTargets = new ArrayList<>();
collectClickable(overlay, pipTargets);
assertTrue("PiP must expose no active overlay targets", pipTargets.isEmpty());
int before = callbacks.actionCount();
tapVideo();
doubleTapVideo(0.18f);
doubleTapVideo(0.82f);
idle(4000);
assertEquals(before, callbacks.actionCount());
assertChrome(false);
}
@Test public void errorClearsLockAndPresentsReachableRecovery() {
tapControl("player-lock");
assertTrue(visible(tagged("player-unlock")));
overlay.render(state(false, true, false));
layout(width, height);
assertFalse("Error must clear lock", visible(tagged("player-unlock")));
View error = tagged("player-error");
assertTrue("Recovery panel must be visible", visible(error));
List<View> recoveryButtons = new ArrayList<>();
collectClickable(error, recoveryButtons);
assertFalse("Recovery panel must offer an actionable control", recoveryButtons.isEmpty());
// Click real recovery controls; onRetry(false/true) must be reachable, not just text.
for (View button : recoveryButtons) {
Rect rect = bounds(button);
rawTap(rect.exactCenterX(), rect.exactCenterY());
}
assertTrue("Retry must be reachable from error recovery UI", callbacks.retries.contains(false));
idle(5000);
assertTrue("Error recovery cannot auto-hide", visible(error));
}
private void collectClickable(View view, List<View> result) {
if (!visible(view)) return;
if (view.isClickable() && view.isEnabled()) result.add(view);
if (view instanceof ViewGroup) {
ViewGroup group = (ViewGroup) view;
for (int i = 0; i < group.getChildCount(); i++) collectClickable(group.getChildAt(i), result);
}
}
@Test public void releaseCancelsPendingAutoHideAndGestureCallbacks() throws Exception {
// Check real Android Handler queue removal, not merely a released-state guard.
rawTap(width * 0.17f, height * 0.27f);
assertChrome(true);
List<android.os.Handler> handlers = new ArrayList<>();
boolean hadPendingCallbacks = false;
for (java.lang.reflect.Field field : overlay.getClass().getDeclaredFields()) {
if (!android.os.Handler.class.isAssignableFrom(field.getType())) continue;
field.setAccessible(true);
android.os.Handler handler = (android.os.Handler) field.get(overlay);
assertSame("Overlay Handler must use Android's main looper", Looper.getMainLooper(), handler.getLooper());
handlers.add(handler);
hadPendingCallbacks |= handler.hasMessages(0); // Runnable posts use what=0.
}
assertFalse("Expected real overlay Handler", handlers.isEmpty());
assertTrue("Test must start with actual timer callbacks queued", hadPendingCallbacks);
int before = callbacks.actionCount();
overlay.release();
released = true;
for (android.os.Handler handler : handlers) {
assertFalse("release left callbacks on the overlay Handler", handler.hasMessages(0));
}
boolean chromeAfterRelease = visible(tagged("player-controls"));
idle(10_000);
assertEquals(before, callbacks.actionCount());
assertEquals("No delayed gesture may change released UI", chromeAfterRelease, visible(tagged("player-controls")));
}
@Test public void closeCallbackReleaseFreezesLastFrameAndLeavesNoCallbacks() throws Exception {
String[] frozen = new String[1];
callbacks.closeHook = () -> {
frozen[0] = uiSnapshot(overlay);
overlay.release();
released = true;
};
tagged("player-back").performClick();
assertEquals(1, callbacks.closes);
assertEquals("Click wrapper must not repaint after callback released overlay", frozen[0], uiSnapshot(overlay));
assertReleasedHasNoCallbacks();
idle(10_000);
assertEquals(frozen[0], uiSnapshot(overlay));
assertEquals(1, callbacks.actionCount());
}
@Test public void gestureCallbackReleaseCannotPaintOrScheduleSeekFeedback() throws Exception {
String[] frozen = new String[1];
callbacks.relativeHook = () -> {
frozen[0] = uiSnapshot(overlay);
overlay.release();
released = true;
};
doubleTapVideo(.82f);
assertNotNull(frozen[0]);
assertEquals("Double tap must stop after callback releases overlay", frozen[0], uiSnapshot(overlay));
assertReleasedHasNoCallbacks();
idle(10_000);
assertEquals(frozen[0], uiSnapshot(overlay));
assertEquals(1, callbacks.actionCount());
}
private void assertReleasedHasNoCallbacks() throws Exception {
for (java.lang.reflect.Field field : overlay.getClass().getDeclaredFields()) {
if (!android.os.Handler.class.isAssignableFrom(field.getType())) continue;
field.setAccessible(true);
android.os.Handler handler = (android.os.Handler) field.get(overlay);
assertFalse("Released overlay scheduled a later callback", handler.hasMessages(0));
}
}
private String uiSnapshot(View view) {
StringBuilder result = new StringBuilder();
result.append(view.getClass().getSimpleName()).append(':').append(view.getTag())
.append(':').append(view.getVisibility()).append(':').append(view.isEnabled())
.append(':').append(view.getAlpha()).append(':').append(view.getContentDescription());
if (view instanceof android.widget.TextView) result.append(':').append(((android.widget.TextView) view).getText());
if (view instanceof ViewGroup) {
ViewGroup group = (ViewGroup) view;
for (int i = 0; i < group.getChildCount(); i++) result.append('[').append(uiSnapshot(group.getChildAt(i))).append(']');
}
return result.toString();
}
@Test public void backgroundStopsTimerAndForegroundResumesIt() {
idle(2000);
overlay.setForeground(false);
idle(5000);
assertChrome(true);
long resumedAt = SystemClock.uptimeMillis();
overlay.setForeground(true);
// Frame traversal inside setForeground may itself advance shadow time.
// Anchor to the interaction, with a small vsync allowance at the boundary.
idleUntil(resumedAt + 2950);
assertChrome(true);
idleUntil(resumedAt + 3050);
assertChrome(false);
}
@Test public void closeAndExternalButtonsDispatchThroughRealTouch() {
tapControl("player-external");
assertEquals(1, callbacks.external);
tapControl("player-back");
assertEquals(1, callbacks.closes);
assertEquals(0, callbacks.toggles);
}
@Test public void safeInsetsProtectControlsWithoutLeavingScrimGap() {
for (int[] size : new int[][] {{360, 780}, {780, 360}}) {
overlay.setSafeInsets(28, 32, 16, 24);
layout(size[0], size[1]);
View header = (View) tagged("player-back").getParent();
assertNotNull("Header owns the actual top shadow", header.getBackground());
assertEquals("Top scrim must start at the physical top, not below the safe area", 0, bounds(header).top);
assertEquals(0, bounds(header).left);
assertEquals(size[0], bounds(header).right);
assertEquals(32, bounds(tagged("player-back")).top);
assertEquals(28, bounds(tagged("player-back")).left);
assertEquals(size[0] - 16, bounds(tagged("player-external")).right);
assertEquals(size[1] - 24, bounds(tagged("player-settings")).bottom);
assertEquals(new Rect(0, 0, size[0], size[1]), bounds(tagged("player-video-gestures")));
overlay.setSafeInsets(0, 0, 0, 0);
layout(size[0], size[1]);
assertEquals("Immersive/PiP inset removal must move controls back to the top", 0,
bounds(tagged("player-back")).top);
}
}
@Test public void portraitControlsStayInsideViewportAndDoNotOverlap() {
assertGeometry(360, 780);
}
@Test public void landscapeControlsStayInsideViewportAndDoNotOverlap() {
assertGeometry(780, 360);
}
private void assertGeometry(int w, int h) {
layout(w, h);
Rect viewport = new Rect(0, 0, w, h);
List<View> controls = new ArrayList<>();
for (String tag : List.of("player-play", "player-back", "player-external", "player-settings",
"player-lock", "player-seek", "player-clock", "player-remaining")) {
View view = tagged(tag);
assertTrue("Required control not visible in " + w + "x" + h + ": " + tag, visible(view));
Rect rect = bounds(view);
System.out.println("HOST_LAYOUT " + w + "x" + h + " " + tag + "=" + rect);
assertTrue("Zero-sized control " + tag + ": " + rect, rect.width() > 0 && rect.height() > 0);
assertTrue("Offscreen control " + tag + ": " + rect + " viewport=" + viewport, viewport.contains(rect));
controls.add(view);
}
// Also cover real extra controls (e.g. PiP and rewind), without prescribing
// extra tags beyond the shared interface or overlooking sibling additions.
List<View> extraTargets = new ArrayList<>();
collectClickable(tagged("player-controls"), extraTargets);
for (View view : extraTargets) {
if (controls.contains(view)) continue;
Rect rect = bounds(view);
System.out.println("HOST_LAYOUT " + w + "x" + h + " " + view.getTag() + "=" + rect);
assertTrue("Zero-sized touch target: " + view.getTag(), rect.width() > 0 && rect.height() > 0);
assertTrue("Offscreen touch target " + view.getTag() + ": " + rect, viewport.contains(rect));
controls.add(view);
}
for (int i = 0; i < controls.size(); i++) {
for (int j = i + 1; j < controls.size(); j++) {
View a = controls.get(i), b = controls.get(j);
assertFalse("Overlapping controls " + a.getTag() + "=" + bounds(a)
+ " and " + b.getTag() + "=" + bounds(b), Rect.intersects(bounds(a), bounds(b)));
}
}
}
}
@@ -0,0 +1,79 @@
package org.reelbox.reelbox.tests
import org.reelbox.reelbox.NativePlayerLaunch
/** JVM-only tests, run without an Android runtime or network. */
fun main() {
val source = "https://media.example.org/a%2Fb.m3u8?sig=abc%2Bz&x=1"
val launch = NativePlayerLaunch.from(mapOf("url" to source, "positionMs" to 12345L,
"autoplay" to false, "title" to "عنوان"), emptyList())
check(launch.url == source)
check(launch.positionMs == 12345L)
check(!launch.autoplay)
check(launch.title == "عنوان")
val root = java.nio.file.Files.createTempDirectory("native-player-policy").toFile()
try {
val local = java.io.File(root, "episode.mkv").apply { writeText("fixture") }
check(NativePlayerLaunch.from(mapOf("url" to local.path), listOf(root)).url == local.canonicalPath)
check(NativePlayerLaunch.from(mapOf("url" to local.toURI().toString()), listOf(root)).url == local.canonicalPath)
val loopback = "http://127.0.0.1:49321/media/version/a.m3u8?token=opaque"
check(!org.reelbox.reelbox.NativeUrlPolicy.allowsRemote(loopback))
check(NativePlayerLaunch.from(mapOf("url" to loopback), emptyList()).url == loopback)
val invalid = listOf("content://foreign/provider", "/etc/passwd", "file:///etc/passwd",
"https://u:p@example.org/media.mp4", "javascript:alert(1)",
java.io.File(root, "../outside.mp4").path, "file://evil.example${local.path}",
"https://t.me/channel", java.io.File(root, "video.apk").apply { writeText("fixture") }.path)
for (url in invalid) {
check(runCatching { NativePlayerLaunch.from(mapOf("url" to url), listOf(root)) }.isFailure)
}
val outside = java.io.File(root.parentFile, "outside-native-test.mkv").apply { writeText("fixture") }
try {
val link = java.io.File(root, "link.mkv")
java.nio.file.Files.createSymbolicLink(link.toPath(), outside.toPath())
check(runCatching { NativePlayerLaunch.from(mapOf("url" to link.path), listOf(root)) }.isFailure)
} finally { outside.delete() }
val options = NativePlayerLaunch.from(mapOf("url" to source, "speed" to 1.25,
"orientation" to "landscape", "videoFit" to "cover", "externalPackage" to "vlc",
"subtitleEnabled" to false, "subtitleUrl" to local.path, "pipEnabled" to false,
"hardwareAcceleration" to false, "positionMs" to -3), listOf(root))
check(options.speed == 1.25f && options.orientation == "landscape" && options.videoFit == "cover")
check(options.externalPackage == "vlc" && !options.subtitleEnabled && options.subtitleUrl == local.canonicalPath)
check(!options.pipEnabled && !options.hardwareAcceleration && options.positionMs == 0L)
check(runCatching { NativePlayerLaunch.from(mapOf("url" to source, "speed" to Double.NaN), emptyList()) }.isFailure)
check(runCatching { NativePlayerLaunch.from(mapOf("url" to source, "externalPackage" to "a;b"), emptyList()) }.isFailure)
} finally { root.deleteRecursively() }
val gate = org.reelbox.reelbox.NativePlayerResultGate()
val delivered = mutableListOf<Map<String, Any>>()
check(gate.begin("title:007/source:a%2Fb", 12000L) { delivered.add(it) })
check(!gate.begin("other", 0L) { error("Busy request replaced") })
check(!gate.update("wrong", 1L, 2L, true, true))
check(!gate.finish("wrong"))
check(delivered.isEmpty())
check(gate.update("title:007/source:a%2Fb", 32000L, 90000L, true, false))
check(gate.finish("title:007/source:a%2Fb"))
check(!gate.finish("title:007/source:a%2Fb"))
check(delivered.single() == mapOf("positionMs" to 32000L, "durationMs" to 90000L,
"opened" to true, "completed" to false))
check(gate.begin("retry", -7L) { delivered.add(it) })
gate.update("retry", Long.MAX_VALUE, 123L, true, true)
gate.finish("retry")
check(delivered.last()["positionMs"] == 123L && delivered.last()["completed"] == true)
check(gate.begin("destroy", 42L) { delivered.add(it) })
gate.dispose()
gate.dispose()
check(delivered.size == 3 && delivered.last()["positionMs"] == 42L && delivered.last()["opened"] == false)
check(gate.begin("reentrant", 0L) { check(gate.begin("next", 0L) {}) })
check(gate.finish("reentrant"))
check(gate.activeId == "next")
gate.dispose()
check(gate.begin("detached-engine", 9L) { throw IllegalStateException("Flutter engine detached") })
check(runCatching { gate.finish("detached-engine") }.getOrDefault(false))
check(gate.activeId == null)
check(!gate.finish("detached-engine"))
check(gate.begin("oversized-metadata", Long.MAX_VALUE) { delivered.add(it) })
gate.update("oversized-metadata", Long.MAX_VALUE, Long.MAX_VALUE, true, false)
gate.finish("oversized-metadata")
check(delivered.last()["durationMs"] == Long.MAX_VALUE / 1000L)
check(delivered.last()["positionMs"] == Long.MAX_VALUE / 1000L)
println("Native player launch/policy and exactly-once result regression cases passed")
}
@@ -0,0 +1,38 @@
package org.reelbox.reelbox.tests
import org.reelbox.reelbox.NativeUrlPolicy
/** JVM executable: no Android runtime, credentials, network, or Gradle. */
fun main() {
var assertions = 0
fun expect(condition: Boolean, message: () -> Any = { "Assertion failed" }) {
assertions++
check(condition, message)
}
expect(NativeUrlPolicy.allowsRemote("https://media.example.org/movie.m3u8?token=opaque"))
expect(NativeUrlPolicy.allowsRemote("http://192.168.1.4/video.mp4"))
for (url in listOf(
"javascript:alert(1)", "intent://play", "file:///sdcard/video.mp4", "https://t.me/channel",
"https://example.org/app.APK?download=1", "https://example.org/%75pdate/latest",
"https://example.org/support/contact", "https://example.org/telegram", "https://melodybox.example/store",
"https://norton.com/promo", "https://example.org/news", "https://example.org/shop",
"https://user:pass@example.org/video.mp4", "https://example.org/app%252eapk",
"https://example.org/مَلُودِي%20باكس", "https://example.org/نُورْتُون",
"https://example.org/setup.exe", "https://example.org/app.dmg", "https://example.org/install.ipa",
"https://example.org/video%5cplayer.mp4", "https://example.org/%2575pdate/latest",
"https://example.org/%D8%AA%D9%84%DA%AF%D8%B1%D8%A7%D9%85",
"https://example.org/?next=https%3A%2F%2Ft.me%2Fchannel", "https://example.org/?next=melody&#98;ox"
)) expect(!NativeUrlPolicy.allowsRemote(url)) { "Unsafe URL accepted: $url" }
expect(NativeUrlPolicy.safePackage("org.videolan.vlc"))
expect(!NativeUrlPolicy.safePackage("bad;package"))
expect(!NativeUrlPolicy.allowsSharedFilename("update.APK"))
expect(!NativeUrlPolicy.allowsSharedFilename("installer.msi"))
expect(NativeUrlPolicy.allowsSharedFilename("episode.mkv"))
expect(NativeUrlPolicy.allowsSharedFilename("subtitle.vtt"))
val first = org.reelbox.reelbox.PlaybackSnapshot.from(mapOf("title" to "Film", "playing" to true, "positionMs" to -20, "durationMs" to 100))
expect(first.positionMs == 0L && first.playing && first.title == "Film")
val next = org.reelbox.reelbox.PlaybackSnapshot.from(mapOf("positionMs" to 999), first)
expect(next.positionMs == 100L && next.durationMs == 100L && next.title == "Film")
expect(org.reelbox.reelbox.PlaybackSnapshot.from(mapOf("durationMs" to -9)).durationMs == 0L)
println("Native URL policy and playback snapshots: $assertions assertions passed")
}
@@ -0,0 +1,178 @@
package org.reelbox.reelbox.tests
import org.reelbox.reelbox.PlayerControlsState
import org.reelbox.reelbox.PlayerMenuSession
import org.reelbox.reelbox.PlayerSeekMath
import java.math.BigInteger
private var passed = 0
private var failed = 0
private fun case(name: String, run: () -> Unit) {
try {
run()
passed++
println("PASS $name")
} catch (failure: Throwable) {
failed++
println("FAIL $name: ${failure.message}")
}
}
private fun expect(value: Boolean, message: String) { check(value) { message } }
fun main() {
case("playing idle hides at precisely 3000ms; progress renders preserve deadline") {
val controls = PlayerControlsState()
controls.playback(playing = true, loading = false, error = false, pip = false, now = 0)
for (now in listOf(500L, 1000L, 2000L, 2999L)) {
controls.playback(true, false, false, false, now)
controls.tick(now)
expect(controls.chromeVisible, "progress render hid controls before deadline")
}
controls.tick(3000)
expect(!controls.chromeVisible, "idle playback did not hide")
controls.playback(true, false, false, false, 3500)
expect(!controls.chromeVisible, "progress render reopened controls")
}
case("buffering does not force chrome or consume the playing deadline") {
val controls = PlayerControlsState()
controls.playback(true, true, false, false, 0)
controls.tick(5000)
expect(controls.chromeVisible, "initial buffering hid controls before actual playback")
controls.playback(true, false, false, false, 6000)
controls.tick(8999)
expect(controls.chromeVisible, "buffering consumed playback idle time")
controls.tick(9000)
expect(!controls.chromeVisible, "playback did not hide after buffering")
}
case("PiP suppresses chrome and cancels timers; exit restores controls") {
val controls = PlayerControlsState()
controls.playback(true, false, false, true, 0)
expect(!controls.chromeVisible, "PiP retained controls")
expect(controls.deadlineMs == null, "PiP retained timer")
controls.playback(true, false, false, false, 5000)
expect(controls.chromeVisible, "PiP exit did not restore controls")
}
case("error reveals recovery after idle hide") {
val controls = PlayerControlsState()
controls.playback(true, false, false, false, 0)
controls.tick(3000)
controls.playback(false, false, true, false, 4000)
expect(controls.chromeVisible, "error recovery remained hidden")
expect(controls.deadlineMs == null, "error scheduled auto hide")
}
case("manual tap hides paused and loading controls without reopening on ticks") {
for (loading in listOf(false, true)) {
val controls = PlayerControlsState()
controls.playback(false, loading, false, false, 0)
controls.tap(100)
expect(!controls.chromeVisible, "manual tap did not hide paused/loading chrome")
controls.playback(false, loading, false, false, 1000)
expect(!controls.chromeVisible, "render reopened manually hidden controls")
controls.tap(1200)
expect(controls.chromeVisible, "manual tap did not restore controls")
}
}
case("seeking and menus hold visibility then restart idle timeout on release") {
for (menu in listOf(false, true)) {
val controls = PlayerControlsState()
controls.playback(true, false, false, false, 0)
if (menu) controls.menu(true, 2500) else controls.seeking(true, 2500)
controls.tick(12000)
expect(controls.chromeVisible && controls.deadlineMs == null, "active interaction auto hid")
if (menu) controls.menu(false, 12000) else controls.seeking(false, 12000)
controls.tick(14999)
expect(controls.chromeVisible, "interaction release did not grant full idle interval")
controls.tick(15000)
expect(!controls.chromeVisible, "interaction release never auto hid")
}
}
case("lock suppresses controls, unlock restores, error always unlocks recovery") {
val controls = PlayerControlsState()
controls.playback(true, false, false, false, 0)
controls.lock(true, 100)
expect(controls.locked && !controls.chromeVisible, "lock retained chrome")
controls.tap(200)
expect(!controls.chromeVisible, "tap bypassed lock")
controls.lock(false, 300)
expect(controls.chromeVisible && !controls.locked, "unlock did not restore controls")
controls.lock(true, 400)
controls.playback(false, false, true, false, 500)
expect(!controls.locked && controls.chromeVisible, "error recovery trapped behind lock")
}
case("PiP suppresses lock affordance and ignores video interaction") {
val controls = PlayerControlsState()
controls.playback(true, false, false, false, 0)
controls.lock(true, 100)
controls.playback(true, false, false, true, 200)
controls.tap(300)
expect(!controls.chromeVisible && !controls.unlockVisible, "PiP overlay leakage")
}
case("background and release cancel scheduling; release ignores all later events") {
val controls = PlayerControlsState()
controls.playback(true, false, false, false, 0)
controls.foreground(false, 500)
expect(controls.deadlineMs == null, "background timer remained live")
controls.foreground(true, 7000)
expect(controls.deadlineMs == 10000L, "foreground did not grant fresh idle interval")
controls.release()
controls.tap(9000)
controls.playback(true, false, true, false, 10000)
controls.foreground(true, 12000)
expect(controls.deadlineMs == null && !controls.chromeVisible, "released policy scheduled or reopened")
}
case("explicit control interaction renews timeout; overlapping holds release independently") {
val controls = PlayerControlsState()
controls.playback(true, false, false, false, 0)
controls.interact(2500)
controls.tick(3000)
expect(controls.chromeVisible, "button interaction failed to renew timeout")
controls.seeking(true, 4000)
controls.menu(true, 4200)
controls.seeking(false, 5000)
expect(controls.deadlineMs == null, "seek completion ignored open menu")
controls.menu(false, 6000)
expect(controls.deadlineMs == 9000L, "last hold release did not restart timer")
}
case("old sheet dismissal cannot end a newer sheet's visibility hold") {
val menus = PlayerMenuSession()
val old = menus.open()
val current = menus.open()
expect(!menus.close(old), "late old onDismiss cleared the new sheet hold")
expect(menus.close(current), "current sheet could not release its hold")
expect(!menus.close(current), "duplicate dismissal released a hold twice")
}
case("normal progress conversion preserves floor millisecond targets") {
expect(PlayerSeekMath.fromProgress(120_000, 2500) == 30_000L, "normal quarter seek")
expect(PlayerSeekMath.fromProgress(123_457, 3333) == 41_148L, "fractional millisecond flooring")
}
case("accepted native maximum duration reaches exact end without overflow") {
val duration = Long.MAX_VALUE / 1000L
expect(PlayerSeekMath.fromProgress(duration, 10000) == duration, "native maximum wrapped before clamp")
}
case("full Long duration progress matches arbitrary precision across boundaries") {
for (duration in listOf(0L, 1L, 9999L, 10_001L, Long.MAX_VALUE / 1000, Long.MAX_VALUE)) {
for (progress in listOf(0, 1, 3333, 5000, 9999, 10000)) {
val expected = BigInteger.valueOf(duration).multiply(BigInteger.valueOf(progress.toLong()))
.divide(BigInteger.valueOf(10000)).toLong()
expect(PlayerSeekMath.fromProgress(duration, progress) == expected,
"duration=$duration progress=$progress expected=$expected")
}
}
}
case("progress clamps invalid duration and out-of-range thumb values") {
expect(PlayerSeekMath.fromProgress(-1, 5000) == 0L, "negative duration")
expect(PlayerSeekMath.fromProgress(Long.MIN_VALUE, 10000) == 0L, "minimum duration")
expect(PlayerSeekMath.fromProgress(120_000, -1) == 0L, "negative progress")
expect(PlayerSeekMath.fromProgress(120_000, Int.MAX_VALUE) == 120_000L, "oversized progress")
}
case("relative seek saturates rather than wrapping and clamps negative positions") {
expect(PlayerSeekMath.relative(30_000, 10_000) == 40_000L, "normal forward")
expect(PlayerSeekMath.relative(30_000, -10_000) == 20_000L, "normal rewind")
expect(PlayerSeekMath.relative(Long.MAX_VALUE - 5, 10_000) == Long.MAX_VALUE, "positive overflow")
expect(PlayerSeekMath.relative(Long.MAX_VALUE, Long.MAX_VALUE) == Long.MAX_VALUE, "max delta overflow")
expect(PlayerSeekMath.relative(5, Long.MIN_VALUE) == 0L, "rewind below zero")
expect(PlayerSeekMath.relative(-5, 10) == 10L, "negative starting position")
}
println("Player controls: $passed tests passed; $failed failed")
check(failed == 0) { "$failed controls regression(s) failed" }
}
@@ -0,0 +1,353 @@
package org.reelbox.reelbox.tests
import com.sun.net.httpserver.HttpServer
import org.reelbox.reelbox.RestrictedResourceFetcher
import java.net.InetSocketAddress
import java.net.HttpURLConnection
import java.net.URL
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
import javax.net.ssl.SSLHandshakeException
import java.util.concurrent.CopyOnWriteArrayList
/** Deterministic connection seam: not a device TLS or WebView simulation. */
private class FixtureConnection(
url: URL,
private val code: Int = 200,
private val fields: Map<String, List<String>> = emptyMap(),
private val payload: InputStream = ByteArrayInputStream("public".toByteArray()),
private val failure: IOException? = null,
private val phrase: String = "",
) : HttpURLConnection(url) {
var closed = false
var statusReads = 0
override fun connect() {}
override fun disconnect() { closed = true }
override fun usingProxy() = false
override fun getResponseCode(): Int {
check(!instanceFollowRedirects) { "Automatic redirects enabled" }
check(connectTimeout in 1..30_000 && readTimeout in 1..30_000) { "Unbounded network wait" }
statusReads++
failure?.let { throw it }
return code
}
override fun getResponseMessage(): String = phrase
override fun getHeaderFields(): Map<String, List<String>> = fields
override fun getHeaderField(name: String): String? = fields.entries.firstOrNull { it.key.equals(name, true) }?.value?.firstOrNull()
override fun getInputStream(): InputStream = payload
override fun getErrorStream(): InputStream = payload
}
/** Real loopback HTTP; no remote hosts, accounts, Android runtime or Gradle. */
fun main(args: Array<String>) {
val selected = args.firstOrNull() ?: "all"
var passed = 0
fun test(name: String, body: () -> Unit) {
if (selected != "all" && selected != name) return
body()
passed++
println("PASS $name")
}
test("blocked_redirect") {
val hits = CopyOnWriteArrayList<String>()
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/") { exchange ->
hits.add(exchange.requestURI.path)
if (exchange.requestURI.path == "/redirect.js") {
exchange.responseHeaders.add("Location", "/news/script.js")
exchange.sendResponseHeaders(302, -1)
} else {
val bytes = "forbidden-marker".toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
exchange.close()
}
server.start()
try {
val url = "http://127.0.0.1:${server.address.port}/redirect.js"
val response = RestrictedResourceFetcher().fetch(url)
response.body.close()
check(response.statusCode == 403) { "Forbidden redirect must fail closed" }
check(hits == listOf("/redirect.js")) { "Forbidden target requested: $hits" }
} finally { server.stop(0) }
}
test("allowed_redirect_metadata") {
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/") { exchange ->
if (exchange.requestURI.path == "/entry.js") {
exchange.responseHeaders.add("Location", "./assets/player.js")
exchange.sendResponseHeaders(307, -1)
} else {
val bytes = "public-player".toByteArray()
exchange.responseHeaders.add("Content-Type", "application/javascript; charset=\"UTF-8\"")
exchange.responseHeaders.add("Access-Control-Allow-Origin", "https://embed.example.org")
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
exchange.close()
}
server.start()
try {
val base = "http://127.0.0.1:${server.address.port}"
val response = RestrictedResourceFetcher().fetch("$base/entry.js")
check(response.statusCode == 200 && response.reasonPhrase.isNotBlank())
check(response.mimeType == "application/javascript" && response.encoding == "UTF-8")
check(response.effectiveUrl == "$base/assets/player.js")
check(response.headers.entries.any { it.key.equals("Access-Control-Allow-Origin", true) && it.value == "https://embed.example.org" })
check(response.body.use { String(it.readBytes()) } == "public-player")
for (path in listOf("/collections/private.m3u8", "/episodes/playlist.m3u8")) {
val playlist = RestrictedResourceFetcher().fetch("$base$path")
check(playlist.statusCode == 200 && playlist.body.use { String(it.readBytes()) } == "public-player")
}
} finally { server.stop(0) }
}
test("redirect_boundaries") {
val hits = CopyOnWriteArrayList<String>()
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/") { exchange ->
val path = exchange.requestURI.path
hits.add(path)
val target = when (path) {
"/loop.js" -> "/loop.js"
"/malformed.js" -> "http://[invalid"
"/empty.js" -> null
"/chain.js" -> "/second.js"
"/second.js" -> "/%256eews/script.js"
else -> "/news/script.js"
}
if (target != null) exchange.responseHeaders.add("Location", target)
exchange.sendResponseHeaders(path.removePrefix("/code-").toIntOrNull() ?: 302, -1)
exchange.close()
}
server.start()
try {
val fetcher = RestrictedResourceFetcher()
val base = "http://127.0.0.1:${server.address.port}"
val loop = fetcher.fetch("$base/loop.js")
loop.body.close()
check(loop.statusCode == 508 && hits.size == 6) { "Redirect budget must be five: ${loop.statusCode}, ${hits.size}" }
for (path in listOf("malformed.js", "empty.js")) {
val response = fetcher.fetch("$base/$path")
response.body.close()
check(response.statusCode == 502)
}
hits.clear()
val chain = fetcher.fetch("$base/chain.js")
chain.body.close()
check(chain.statusCode == 403 && hits == listOf("/chain.js", "/second.js"))
for (code in listOf(301, 302, 303, 307, 308)) {
hits.clear()
val response = fetcher.fetch("$base/code-$code")
response.body.close()
check(response.statusCode == 403 && hits == listOf("/code-$code"))
}
hits.clear()
for (url in listOf("not a URI", "$base/news/script.js", "file:///unreadable", "content://unreadable", "data:text/plain,test")) {
val response = fetcher.fetch(url)
response.body.close()
check(response.statusCode == 403)
}
check(hits.isEmpty())
} finally { server.stop(0) }
}
test("range_head_and_headers") {
val seen = CopyOnWriteArrayList<Pair<String, Map<String, List<String>>>>()
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/") { exchange ->
seen.add(exchange.requestMethod to exchange.requestHeaders.toMap())
if (exchange.requestURI.path == "/entry.mp4") {
exchange.responseHeaders.add("Location", "/video.mp4")
exchange.sendResponseHeaders(307, -1)
exchange.close()
return@createContext
}
exchange.responseHeaders.add("Content-Type", "video/mp4")
exchange.responseHeaders.add("Accept-Ranges", "bytes")
exchange.responseHeaders.add("Content-Range", "bytes 2-5/10")
exchange.responseHeaders.add("Set-Cookie", "fixture=public; Path=/")
exchange.responseHeaders.add("Connection", "keep-alive, X-Hop-Only")
exchange.responseHeaders.add("X-Hop-Only", "must-not-forward")
exchange.responseHeaders.add("ETag", "\"fixture-range\"")
if (exchange.requestMethod == "HEAD") {
exchange.responseHeaders.add("Content-Length", "4")
exchange.sendResponseHeaders(206, -1)
} else {
exchange.sendResponseHeaders(206, 4)
exchange.responseBody.use { it.write("2345".toByteArray()) }
}
exchange.close()
}
server.start()
try {
val fetcher = RestrictedResourceFetcher()
val url = "http://127.0.0.1:${server.address.port}/entry.mp4"
val requestHeaders = mapOf("Range" to "bytes=2-5", "If-Range" to "\"fixture-range\"",
"User-Agent" to "PublicEmbedFixture", "Authorization" to "fixture-only",
"Cookie" to "fixture=not-a-real-session", "Proxy-Authorization" to "fixture-only",
"X-Api-Key" to "fixture-only", "Accept-Encoding" to "gzip", "Host" to "wrong.invalid",
"If-None-Match" to "\"fixture\"")
for (method in listOf("GET", "HEAD")) {
val response = fetcher.fetch(url, method, requestHeaders)
check(response.statusCode == 206 && response.mimeType == "video/mp4" && response.encoding == null)
check(response.headers.entries.any { it.key.equals("Content-Range", true) && it.value == "bytes 2-5/10" })
check(response.headers.entries.any { it.key.equals("Content-Length", true) && it.value == "4" })
check(response.headers.keys.none { it.lowercase() in listOf("set-cookie", "connection", "x-hop-only", "transfer-encoding") })
check(response.body.use { String(it.readBytes()) } == if (method == "HEAD") "" else "2345")
}
check(seen.map { it.first } == listOf("GET", "GET", "HEAD", "HEAD"))
for ((_, headers) in seen) {
fun header(name: String) = headers.entries.firstOrNull { it.key.equals(name, true) }?.value?.singleOrNull()
check(header("Range") == "bytes=2-5" && header("If-Range") == "\"fixture-range\"")
check(header("User-Agent") == "PublicEmbedFixture" && header("Accept-Encoding") == "identity")
check(listOf("Authorization", "Cookie", "Proxy-Authorization", "X-Api-Key", "If-None-Match").all { header(it) == null })
check(header("Host") != "wrong.invalid")
}
for (method in listOf("POST", "PUT", "DELETE", "OPTIONS")) {
val response = fetcher.fetch(url, method, requestHeaders)
response.body.close()
check(response.statusCode == 405)
}
check(seen.size == 4) { "Unsupported methods must not be sent" }
} finally { server.stop(0) }
}
test("cross_origin_headers") {
val seen = CopyOnWriteArrayList<Map<String, List<String>>>()
val first = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
val second = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
val origin = "http://127.0.0.1:${first.address.port}"
first.createContext("/") { exchange ->
seen.add(exchange.requestHeaders.toMap())
if (exchange.requestURI.path == "/entry.js") {
exchange.responseHeaders.add("Location", "http://localhost:${second.address.port}/middle.js")
exchange.sendResponseHeaders(302, -1)
} else {
exchange.sendResponseHeaders(200, 2)
exchange.responseBody.use { it.write("ok".toByteArray()) }
}
exchange.close()
}
second.createContext("/") { exchange ->
seen.add(exchange.requestHeaders.toMap())
exchange.responseHeaders.add("Location", "$origin/final.js")
exchange.sendResponseHeaders(308, -1)
exchange.close()
}
first.start(); second.start()
try {
val response = RestrictedResourceFetcher().fetch("$origin/entry.js", "GET", mapOf(
"Origin" to origin, "Referer" to "$origin/embed?fixture=public", "Range" to "bytes=0-1",
"Cookie" to "fixture-only", "Authorization" to "fixture-only", "X-Private" to "fixture-only"))
check(response.body.use { String(it.readBytes()) } == "ok")
check(seen.size == 3)
fun header(index: Int, name: String) = seen[index].entries.firstOrNull { it.key.equals(name, true) }?.value?.singleOrNull()
// The JDK suppresses Origin as a restricted header; Android may send it.
check(header(0, "Referer") == "$origin/embed?fixture=public") { "Initial public Referer missing: ${header(0, "Referer")}" }
for (index in 1..2) check(header(index, "Origin") == null && header(index, "Referer") == null)
for (index in 0..2) {
check(header(index, "Range") == "bytes=0-1")
check(listOf("Cookie", "Authorization", "X-Private").all { header(index, it) == null })
}
} finally { first.stop(0); second.stop(0) }
}
test("tls_downgrade_and_stream_ownership") {
val opened = mutableListOf<FixtureConnection>()
val fetcher = RestrictedResourceFetcher(openConnection = { url ->
FixtureConnection(url, 302, mapOf("Location" to listOf("http://media.example.org/video.mp4"))).also { opened.add(it) }
})
val downgrade = fetcher.fetch("https://media.example.org/entry")
downgrade.body.close()
check(downgrade.statusCode == 403 && opened.size == 1 && opened.single().closed)
val mixed = fetcher.fetch("http://media.example.org/video.mp4", secureContext = true)
mixed.body.close()
check(mixed.statusCode == 403 && opened.size == 1)
val tlsConnection = FixtureConnection(URL("https://media.example.org/video.mp4"), failure = SSLHandshakeException("fixture untrusted peer"))
val tls = RestrictedResourceFetcher(openConnection = { tlsConnection }).fetch(tlsConnection.url.toString())
tls.body.close()
check(tls.statusCode == 502 && tlsConnection.closed && tlsConnection.statusReads == 1)
var reads = 0
var closes = 0
val payload = object : InputStream() {
override fun read(): Int { reads++; return if (reads == 1) 65 else -1 }
override fun close() { closes++ }
}
val connection = FixtureConnection(URL("https://media.example.org/video.mp4"), payload = payload)
val response = RestrictedResourceFetcher(openConnection = { connection }).fetch(connection.url.toString())
check(response.statusCode == 200 && reads == 0 && !connection.closed) { "Body was buffered or closed before streaming" }
check(response.reasonPhrase.isNotBlank())
check(response.body.read() == 65)
response.body.close()
check(connection.closed && closes == 1)
}
test("android_status_and_transport_failures") {
val invalidPhrase = FixtureConnection(URL("https://media.example.org/player.js"), phrase = "OK\r\nfixture\u00e9")
val safePhrase = RestrictedResourceFetcher(openConnection = { invalidPhrase }).fetch(invalidPhrase.url.toString())
safePhrase.body.close()
check(safePhrase.reasonPhrase.isNotBlank() && safePhrase.reasonPhrase.all { it.code in 32..126 })
// WebResourceResponse accepts 100..299/400..599, never 3xx or invalid codes.
for (code in listOf(-1, 99, 300, 304, 305, 306, 399, 600)) {
val connection = FixtureConnection(URL("https://media.example.org/player.js"), code)
val response = RestrictedResourceFetcher(openConnection = { connection }).fetch(connection.url.toString())
response.body.close()
check(response.statusCode == 502 && connection.closed) { "Invalid WebView status escaped: $code -> ${response.statusCode}" }
}
for (failure in listOf(IOException("fixture connection failure"), java.net.SocketTimeoutException("fixture timeout"))) {
val connection = FixtureConnection(URL("https://media.example.org/player.js"), failure = failure)
val response = RestrictedResourceFetcher(openConnection = { connection }).fetch(connection.url.toString())
check(response.statusCode == 502 && response.body.use { it.read() } == -1 && connection.closed)
}
}
test("access_denial_passthrough") {
val hits = CopyOnWriteArrayList<String>()
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/") { exchange ->
hits.add(exchange.requestURI.path)
val code = exchange.requestURI.path.drop(1).toInt()
val bytes = "fixture-denied-$code".toByteArray()
exchange.responseHeaders.add("Content-Type", "text/plain; charset=UTF-8")
exchange.responseHeaders.add("WWW-Authenticate", "Basic realm=fixture")
exchange.sendResponseHeaders(code, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
exchange.close()
}
server.start()
try {
for (code in listOf(401, 403, 451, 404, 416)) {
val response = RestrictedResourceFetcher().fetch("http://127.0.0.1:${server.address.port}/$code")
check(response.statusCode == code && response.mimeType == "text/plain" && response.encoding == "UTF-8")
check(response.body.use { String(it.readBytes()) } == "fixture-denied-$code")
}
check(hits == listOf("/401", "/403", "/451", "/404", "/416")) { "Access denials must not cause retries: $hits" }
} finally { server.stop(0) }
}
test("excluded_redirect_destinations") {
val hits = CopyOnWriteArrayList<String>()
var destination = ""
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/") { exchange ->
hits.add(exchange.requestURI.path)
exchange.responseHeaders.add("Location", destination)
exchange.sendResponseHeaders(302, -1)
exchange.close()
}
server.start()
try {
val base = "http://127.0.0.1:${server.address.port}"
for (target in listOf("/news/script.js", "/support/help", "/updates/latest", "/setup.apk",
"/melodybox/embed", "/%2575pdater/latest", "https://t.me/fixture", "/live/channel.m3u8",
"/channels/example", "/public-playlists/items", "/public_playlists/items", "/?action=showlist")) {
hits.clear()
destination = if (target.startsWith("/")) "$base$target" else target
for (method in listOf("GET", "HEAD")) {
val response = RestrictedResourceFetcher().fetch("$base/entry.js", method)
response.body.close()
check(response.statusCode == 403) { "Excluded redirect permitted: $target ($method)" }
}
check(hits == listOf("/entry.js", "/entry.js")) { "Excluded target contacted: $target -> $hits" }
}
} finally { server.stop(0) }
}
check(passed > 0) { "Unknown case: $selected" }
println("Restricted resource fetch: $passed cases passed")
}