[verified] Publish ReelBox source under GPLv3
This commit is contained in:
commit
db2b505ca3
445 files changed
+105274
No files matched your search
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -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>
|
||||
@@ -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("&", "&").replace(" ", " ")
|
||||
}
|
||||
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=melodybox"
|
||||
)) 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")
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
// Beta packaging on the constrained host skips automatic release-lint
|
||||
// tasks in dependencies too. Standalone lintRelease remains a separate gate.
|
||||
tasks.configureEach {
|
||||
if (name.contains("lintVital", ignoreCase = true)) {
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
org.gradle.jvmargs=-Xmx512m -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=48m -XX:+UseSerialGC -XX:+ExitOnOutOfMemoryError
|
||||
org.gradle.daemon=false
|
||||
org.gradle.parallel=false
|
||||
org.gradle.workers.max=1
|
||||
kotlin.compiler.execution.strategy=in-process
|
||||
android.useAndroidX=true
|
||||
android.newDsl=false
|
||||
android.builtInKotlin=false
|
||||
disable-abi-filtering=true
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn ( ) {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die ( ) {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
|
||||
function splitJvmOpts() {
|
||||
JVM_OPTS=("$@")
|
||||
}
|
||||
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
|
||||
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
|
||||
|
||||
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windowz variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
if "%@eval[2+2]" == "4" goto 4NT_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
goto execute
|
||||
|
||||
:4NT_args
|
||||
@rem Get arguments from the 4NT Shell from JP Software
|
||||
set CMD_LINE_ARGS=%$
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.1.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
Reference in new issue
Block a user