fix: lecteurs, proxies locaux et mise à jour Android 2.5.4

- Corrige l’extraction Uqload dans les extensions Chrome/Firefox et le userscript
- Ajoute un proxy média local Android avec streaming binaire
- Corrige Vidzy, Fsvid, Bravo et les autres sources protégées sur mobile
- Préserve correctement les en-têtes Origin, Referer et Range
- Ajoute la réécriture locale des playlists HLS et de leurs segments
- Corrige la reprise de l’installation après l’autorisation des sources inconnues
- Corrige la détection d’une installation annulée ou terminée
- Passe l’application en version 2.5.4, build 13
- Rend cette mise à jour obligatoire et regroupe toutes les notes de version
- Régénère le userscript mobile et l’APK Android signé
- Ajoute des tests de régression Kotlin

Co-authored-by: GPT-5.6 Sol <codex@openai.com>
This commit is contained in:
Movix 2026-07-27 15:18:25 +02:00
parent 7c41c90513
commit 74b9f9448d
36 changed files with 2639 additions and 294 deletions

View file

@ -20,8 +20,8 @@ android {
applicationId "com.movix.app"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 12
versionName "2.5.3"
versionCode 13
versionName "2.5.4"
buildConfigField "int", "VERSION_CODE_INT", "${versionCode}"
buildConfigField "String", "VERSION_NAME_STR", "\"${versionName}\""
@ -66,16 +66,10 @@ android {
dependencies {
implementation("com.facebook.react:react-android")
implementation("com.facebook.react:hermes-android")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation("junit:junit:4.13.2")
// Google Cast (Chromecast) see app/src/main/java/com/movix/app/cast/
implementation("com.google.android.gms:play-services-cast-framework:21.5.0")
implementation("androidx.mediarouter:mediarouter:1.7.0")
}
// androidx.legacy:legacy-support-core-utils:1.0.0 (tiré transitivement par
// mediarouter palette) embarque ses propres classes androidx.autofill.R$attr
// qui entrent en collision avec le module androidx.autofill:autofill:1.1.0
// utilisé par appcompat "duplicate class" au mergeDexRelease.
configurations.all {
exclude group: 'androidx.legacy', module: 'legacy-support-core-utils'
}

View file

@ -6,6 +6,13 @@
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/vnd.android.package-archive" />
</intent>
</queries>
<application
android:name=".MainApplication"
android:label="@string/app_name"
@ -34,8 +41,8 @@
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="movix.cash" />
<data android:scheme="https" android:host="*.movix.cash" />
<data android:scheme="https" android:host="movix.tax" />
<data android:scheme="https" android:host="*.movix.tax" />
</intent-filter>
</activity>

View file

@ -8,6 +8,7 @@ import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.soloader.SoLoader
import com.movix.app.CastPackage
import com.movix.app.proxy.MediaProxyPackage
import com.movix.app.update.UpdatePackage
class MainApplication : Application(), ReactApplication {
@ -19,6 +20,7 @@ class MainApplication : Application(), ReactApplication {
add(DnsPackage())
add(UpdatePackage())
add(CastPackage())
add(MediaProxyPackage())
}
override fun getJSMainModuleName(): String = "index"

View file

@ -0,0 +1,79 @@
package com.movix.app.proxy
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.ReadableType
import java.util.concurrent.Executors
class MediaProxyModule(
reactContext: ReactApplicationContext,
) : ReactContextBaseJavaModule(reactContext) {
private val server = MediaProxyServer()
private val openExecutor = Executors.newFixedThreadPool(2) { task ->
Thread(task, "MovixMediaProxy-Open").apply { isDaemon = true }
}
override fun getName() = "MediaProxy"
@ReactMethod
fun open(
url: String,
method: String,
headers: ReadableMap,
promise: Promise,
) {
openExecutor.execute {
try {
promise.resolve(
server.open(
upstreamUrl = url,
method = method,
headers = readableMapToStrings(headers),
),
)
} catch (_: Throwable) {
promise.reject(
"MEDIA_PROXY_OPEN_FAILED",
"Local media proxy unavailable",
)
}
}
}
override fun invalidate() {
openExecutor.shutdownNow()
server.close()
super.invalidate()
}
private fun readableMapToStrings(input: ReadableMap): Map<String, String> {
val result = linkedMapOf<String, String>()
val iterator = input.keySetIterator()
while (iterator.hasNextKey() && result.size < MAX_HEADERS) {
val key = iterator.nextKey()
if (input.getType(key) != ReadableType.String) continue
val value = input.getString(key) ?: continue
if (
key.length > MAX_HEADER_NAME_LENGTH ||
value.length > MAX_HEADER_VALUE_LENGTH ||
key.contains('\r') ||
key.contains('\n') ||
value.contains('\r') ||
value.contains('\n')
) {
continue
}
result[key] = value
}
return result
}
companion object {
private const val MAX_HEADERS = 32
private const val MAX_HEADER_NAME_LENGTH = 128
private const val MAX_HEADER_VALUE_LENGTH = 8_192
}
}

View file

@ -0,0 +1,20 @@
package com.movix.app.proxy
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class MediaProxyPackage : ReactPackage {
override fun createNativeModules(
reactContext: ReactApplicationContext,
): List<NativeModule> {
return listOf(MediaProxyModule(reactContext))
}
override fun createViewManagers(
reactContext: ReactApplicationContext,
): List<ViewManager<*, *>> {
return emptyList()
}
}

View file

@ -0,0 +1,179 @@
package com.movix.app.proxy
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.net.URI
import java.util.Locale
object MediaProxyPolicy {
private const val MAX_URL_LENGTH = 16_384
private const val MAX_HEADER_VALUE_LENGTH = 8_192
private val tokenPattern = Regex("^[A-Za-z0-9_-]{8,128}$")
private val numericIpv4Pattern = Regex("^\\d{1,3}(?:\\.\\d{1,3}){3}$")
private val uriAttributePattern = Regex("""URI=(["'])(.*?)\1""", RegexOption.IGNORE_CASE)
private val allowedRequestHeaders = mapOf(
"accept" to "Accept",
"accept-language" to "Accept-Language",
"content-type" to "Content-Type",
"if-modified-since" to "If-Modified-Since",
"if-none-match" to "If-None-Match",
"origin" to "Origin",
"range" to "Range",
"referer" to "Referer",
"user-agent" to "User-Agent",
)
private val allowedLocalOverrideHeaders = setOf(
"accept",
"accept-language",
"if-modified-since",
"if-none-match",
"range",
)
fun validatePublicHttpsUrl(
rawUrl: String,
resolver: (String) -> List<InetAddress> = {
InetAddress.getAllByName(it).toList()
},
): URI {
val uri = validateHttpsUrlSyntax(rawUrl)
val host = requireNotNull(uri.host).lowercase(Locale.US)
val addresses = runCatching { resolver(host) }
.getOrElse { throw IllegalArgumentException("Upstream DNS failed") }
require(addresses.isNotEmpty()) { "Upstream DNS returned no address" }
require(addresses.none(::isForbiddenAddress)) {
"Private upstream is forbidden"
}
return uri
}
fun validateHttpsUrlSyntax(rawUrl: String): URI {
require(rawUrl.length in 1..MAX_URL_LENGTH) { "Invalid upstream URL" }
val uri = runCatching { URI(rawUrl) }
.getOrElse { throw IllegalArgumentException("Invalid upstream URL") }
require(uri.scheme?.lowercase(Locale.US) == "https") {
"HTTPS upstream required"
}
require(uri.userInfo == null) { "Upstream credentials are forbidden" }
require(uri.port == -1 || uri.port == 443) { "Unsupported upstream port" }
val host = uri.host?.trim()?.lowercase(Locale.US)
require(!host.isNullOrEmpty()) { "Missing upstream host" }
require(host != "localhost" && !host.endsWith(".localhost")) {
"Loopback upstream is forbidden"
}
if (numericIpv4Pattern.matches(host) || host.contains(':')) {
val literal = runCatching { InetAddress.getByName(host) }
.getOrElse { throw IllegalArgumentException("Invalid upstream address") }
require(!isForbiddenAddress(literal)) { "Private upstream is forbidden" }
}
return uri
}
fun isForbiddenAddress(address: InetAddress): Boolean {
if (
address.isAnyLocalAddress ||
address.isLoopbackAddress ||
address.isLinkLocalAddress ||
address.isSiteLocalAddress ||
address.isMulticastAddress
) {
return true
}
val bytes = address.address
if (address is Inet4Address && bytes.size == 4) {
val first = bytes[0].toInt() and 0xff
val second = bytes[1].toInt() and 0xff
if (first == 0 || first >= 224) return true
if (first == 100 && second in 64..127) return true
if (first == 198 && second in 18..19) return true
}
if (address is Inet6Address && bytes.isNotEmpty()) {
val first = bytes[0].toInt() and 0xff
if (first and 0xfe == 0xfc) return true
}
return false
}
fun sanitizeRequestHeaders(input: Map<String, String>): Map<String, String> {
val output = linkedMapOf<String, String>()
for ((rawName, rawValue) in input) {
val canonicalName = allowedRequestHeaders[rawName.trim().lowercase(Locale.US)]
?: continue
val value = rawValue.trim()
if (
value.isEmpty() ||
value.length > MAX_HEADER_VALUE_LENGTH ||
value.contains('\r') ||
value.contains('\n')
) {
continue
}
output[canonicalName] = value
}
return output
}
fun sanitizeLocalRequestHeaders(input: Map<String, String>): Map<String, String> {
return sanitizeRequestHeaders(
input.filterKeys {
it.trim().lowercase(Locale.US) in allowedLocalOverrideHeaders
},
)
}
fun rewritePlaylist(
playlist: String,
baseUrl: String,
localize: (String) -> String,
): String {
val baseUri = runCatching { URI(baseUrl) }
.getOrElse { throw IllegalArgumentException("Invalid playlist base URL") }
fun rewrite(rawValue: String): String {
val value = rawValue.trim()
if (
value.isEmpty() ||
value.startsWith("data:", ignoreCase = true) ||
value.startsWith("blob:", ignoreCase = true)
) {
return rawValue
}
val absolute = runCatching { baseUri.resolve(value).toString() }
.getOrElse { return rawValue }
return localize(absolute)
}
return playlist.lineSequence().joinToString("\n") { line ->
if (line.isBlank()) {
line
} else if (!line.trimStart().startsWith("#")) {
val leading = line.takeWhile(Char::isWhitespace)
val trailing = line.takeLastWhile(Char::isWhitespace)
leading + rewrite(line.trim()) + trailing
} else {
uriAttributePattern.replace(line) { match ->
val quote = match.groupValues[1]
val value = match.groupValues[2]
"URI=$quote${rewrite(value)}$quote"
}
}
}
}
fun buildLoopbackUrl(
port: Int,
processSecret: String,
sessionId: String,
resourceId: String,
): String {
require(port in 1..65_535) { "Invalid loopback port" }
require(tokenPattern.matches(processSecret)) { "Invalid process secret" }
require(tokenPattern.matches(sessionId)) { "Invalid session id" }
require(tokenPattern.matches(resourceId)) { "Invalid resource id" }
return "http://127.0.0.1:$port/p/$processSecret/$sessionId/$resourceId"
}
}

View file

@ -0,0 +1,521 @@
package com.movix.app.proxy
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.Closeable
import java.io.InputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketException
import java.net.URI
import java.net.UnknownHostException
import java.nio.charset.StandardCharsets
import java.util.Locale
import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import okhttp3.Dns
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
internal interface MediaProxyUpstream {
fun execute(
target: MediaProxyTarget,
localRequestHeaders: Map<String, String>,
): MediaProxyUpstreamResponse
}
internal class MediaProxyUpstreamResponse(
val statusCode: Int,
val statusMessage: String,
val headers: Map<String, String>,
val body: InputStream,
val finalUrl: String,
private val onClose: () -> Unit = {},
) : Closeable {
override fun close() {
try {
body.close()
} finally {
onClose()
}
}
}
internal class OkHttpMediaProxyUpstream(
private val validateUrl: (String) -> URI = {
MediaProxyPolicy.validatePublicHttpsUrl(it)
},
) : MediaProxyUpstream {
private val safeDns = object : Dns {
override fun lookup(hostname: String): List<InetAddress> {
val addresses = Dns.SYSTEM.lookup(hostname)
if (addresses.isEmpty() || addresses.any(MediaProxyPolicy::isForbiddenAddress)) {
throw UnknownHostException("Private or unresolved media host")
}
return addresses
}
}
private val client = OkHttpClient.Builder()
.dns(safeDns)
.followRedirects(false)
.followSslRedirects(false)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(45, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
override fun execute(
target: MediaProxyTarget,
localRequestHeaders: Map<String, String>,
): MediaProxyUpstreamResponse {
val mergedHeaders = linkedMapOf<String, String>()
mergedHeaders.putAll(MediaProxyPolicy.sanitizeRequestHeaders(target.headers))
mergedHeaders.putAll(MediaProxyPolicy.sanitizeLocalRequestHeaders(localRequestHeaders))
if (!mergedHeaders.containsKey("User-Agent")) {
mergedHeaders["User-Agent"] = DEFAULT_USER_AGENT
}
var currentUrl = target.upstreamUrl
repeat(MAX_REDIRECTS + 1) { redirectCount ->
validateUrl(currentUrl)
val headerBuilder = Headers.Builder()
for ((name, value) in mergedHeaders) {
headerBuilder.set(name, value)
}
val requestBuilder = Request.Builder()
.url(currentUrl)
.headers(headerBuilder.build())
if (target.method == "HEAD") {
requestBuilder.head()
} else {
requestBuilder.get()
}
val response = client.newCall(requestBuilder.build()).execute()
val location = response.header("Location")
if (response.code in 300..399 && location != null) {
if (redirectCount >= MAX_REDIRECTS) {
response.close()
throw IllegalStateException("Too many media redirects")
}
val nextUrl = response.request.url.resolve(location)?.toString()
response.close()
currentUrl = nextUrl
?: throw IllegalArgumentException("Invalid media redirect")
return@repeat
}
val responseHeaders = linkedMapOf<String, String>()
for (name in response.headers.names()) {
responseHeaders[name] = response.headers.values(name).joinToString(", ")
}
val responseBody = response.body
return MediaProxyUpstreamResponse(
statusCode = response.code,
statusMessage = response.message,
headers = responseHeaders,
body = responseBody?.byteStream() ?: ByteArrayInputStream(ByteArray(0)),
finalUrl = response.request.url.toString(),
onClose = response::close,
)
}
throw IllegalStateException("Media redirect resolution failed")
}
companion object {
private const val MAX_REDIRECTS = 5
private const val DEFAULT_USER_AGENT =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
}
}
internal class MediaProxyServer(
private val upstream: MediaProxyUpstream = OkHttpMediaProxyUpstream(),
private val validateUrl: (String) -> URI = {
MediaProxyPolicy.validatePublicHttpsUrl(it)
},
private val validateDiscoveredUrl: (String) -> URI =
MediaProxyPolicy::validateHttpsUrlSyntax,
private val sessionStore: MediaProxySessionStore = MediaProxySessionStore(),
) : Closeable {
private val running = AtomicBoolean(false)
private val closed = AtomicBoolean(false)
private val startLock = Any()
private val workerCounter = java.util.concurrent.atomic.AtomicInteger()
private val workers = ThreadPoolExecutor(
4,
32,
30L,
TimeUnit.SECONDS,
ArrayBlockingQueue(128),
{ task ->
Thread(
task,
"MovixMediaProxy-${workerCounter.incrementAndGet()}",
).apply { isDaemon = true }
},
ThreadPoolExecutor.AbortPolicy(),
)
@Volatile
private var serverSocket: ServerSocket? = null
fun open(
upstreamUrl: String,
method: String,
headers: Map<String, String>,
): String {
check(!closed.get()) { "Media proxy is closed" }
val normalizedMethod = method.uppercase(Locale.US)
require(normalizedMethod == "GET" || normalizedMethod == "HEAD") {
"Unsupported media proxy method"
}
val validated = validateUrl(upstreamUrl).toString()
val sanitizedHeaders = MediaProxyPolicy.sanitizeRequestHeaders(headers)
require(sanitizedHeaders.isNotEmpty()) { "Protected media headers required" }
val port = ensureStarted()
return sessionStore.create(
upstreamUrl = validated,
method = normalizedMethod,
headers = sanitizedHeaders,
port = port,
)
}
private fun ensureStarted(): Int = synchronized(startLock) {
serverSocket?.takeIf { !it.isClosed }?.localPort?.let { return it }
check(!closed.get()) { "Media proxy is closed" }
val socket = ServerSocket()
socket.reuseAddress = true
socket.bind(InetSocketAddress(InetAddress.getByName(LOOPBACK_HOST), 0), 64)
serverSocket = socket
running.set(true)
Thread({ acceptLoop(socket) }, "MovixMediaProxy-Acceptor").apply {
isDaemon = true
start()
}
socket.localPort
}
private fun acceptLoop(socket: ServerSocket) {
while (running.get() && !socket.isClosed) {
val client = try {
socket.accept()
} catch (_: SocketException) {
break
} catch (_: Throwable) {
continue
}
if (!client.inetAddress.isLoopbackAddress) {
runCatching { client.close() }
continue
}
try {
workers.execute { handleClient(client) }
} catch (_: Throwable) {
runCatching { client.close() }
}
}
}
private fun handleClient(socket: Socket) {
socket.use { client ->
client.soTimeout = 30_000
val input = BufferedInputStream(client.getInputStream())
val output = BufferedOutputStream(client.getOutputStream())
try {
val requestLine = readAsciiLine(input, MAX_REQUEST_LINE)
?: return
val requestParts = requestLine.split(' ')
if (requestParts.size != 3 || !requestParts[2].startsWith("HTTP/1.")) {
writeError(output, 400, "Bad Request")
return
}
val method = requestParts[0].uppercase(Locale.US)
val path = runCatching { URI(requestParts[1]).path }
.getOrNull()
?: run {
writeError(output, 400, "Bad Request")
return
}
val requestHeaders = readHeaders(input)
val pathParts = path.split('/').filter(String::isNotEmpty)
if (pathParts.size != 4 || pathParts[0] != "p") {
writeError(output, 404, "Not Found")
return
}
val target = sessionStore.resolve(
suppliedSecret = pathParts[1],
sessionId = pathParts[2],
resourceId = pathParts[3],
) ?: run {
writeError(output, 404, "Not Found")
return
}
if (method == "OPTIONS") {
writeHeaders(output, 204, "No Content", emptyMap(), 0L)
return
}
if (method != "GET" && method != "HEAD") {
writeError(output, 405, "Method Not Allowed")
return
}
validateUrl(target.upstreamUrl)
val localHeaders =
MediaProxyPolicy.sanitizeLocalRequestHeaders(requestHeaders)
upstream.execute(target, localHeaders).use { response ->
if (isPlaylist(response)) {
writePlaylistResponse(
output = output,
response = response,
sessionId = pathParts[2],
port = requireNotNull(serverSocket).localPort,
sendBody = method != "HEAD",
)
} else {
writeStreamingResponse(
output = output,
response = response,
sendBody = method != "HEAD",
)
}
}
} catch (_: Throwable) {
runCatching { writeError(output, 502, "Bad Gateway") }
}
}
}
private fun writePlaylistResponse(
output: BufferedOutputStream,
response: MediaProxyUpstreamResponse,
sessionId: String,
port: Int,
sendBody: Boolean,
) {
val original = readLimited(response.body, MAX_PLAYLIST_BYTES)
.toString(StandardCharsets.UTF_8)
val rewritten = MediaProxyPolicy.rewritePlaylist(
playlist = original,
baseUrl = response.finalUrl,
) { discoveredUrl ->
val validated = validateDiscoveredUrl(discoveredUrl).toString()
sessionStore.register(sessionId, validated, port)
}
val bytes = rewritten.toByteArray(StandardCharsets.UTF_8)
val headers = filteredResponseHeaders(response.headers).toMutableMap()
headers["Content-Type"] =
getHeader(response.headers, "Content-Type")
?: "application/vnd.apple.mpegurl"
headers["Content-Length"] = bytes.size.toString()
writeHeaders(
output,
response.statusCode,
response.statusMessage,
headers,
bytes.size.toLong(),
)
if (sendBody) output.write(bytes)
output.flush()
}
private fun writeStreamingResponse(
output: BufferedOutputStream,
response: MediaProxyUpstreamResponse,
sendBody: Boolean,
) {
val headers = filteredResponseHeaders(response.headers)
val contentLength = getHeader(response.headers, "Content-Length")?.toLongOrNull()
writeHeaders(
output,
response.statusCode,
response.statusMessage,
headers,
contentLength,
)
if (sendBody) {
response.body.copyTo(output, DEFAULT_BUFFER_SIZE)
}
output.flush()
}
private fun writeHeaders(
output: BufferedOutputStream,
statusCode: Int,
statusMessage: String,
headers: Map<String, String>,
contentLength: Long?,
) {
val safeMessage = statusMessage.replace(Regex("[^\\x20-\\x7E]"), "")
.ifBlank { defaultReason(statusCode) }
val lines = StringBuilder()
.append("HTTP/1.1 ")
.append(statusCode)
.append(' ')
.append(safeMessage)
.append("\r\n")
for ((name, value) in headers) {
if (
name.equals("Connection", ignoreCase = true) ||
name.equals("Transfer-Encoding", ignoreCase = true) ||
name.equals("Access-Control-Allow-Origin", ignoreCase = true)
) {
continue
}
if (value.contains('\r') || value.contains('\n')) continue
lines.append(name).append(": ").append(value).append("\r\n")
}
if (contentLength != null && headers.keys.none {
it.equals("Content-Length", ignoreCase = true)
}
) {
lines.append("Content-Length: ").append(contentLength).append("\r\n")
}
lines
.append("Access-Control-Allow-Origin: *\r\n")
.append("Access-Control-Allow-Methods: GET, HEAD, OPTIONS\r\n")
.append("Access-Control-Allow-Headers: Range, Accept, Content-Type\r\n")
.append("Access-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges\r\n")
.append("Connection: close\r\n\r\n")
output.write(lines.toString().toByteArray(StandardCharsets.ISO_8859_1))
output.flush()
}
private fun writeError(
output: BufferedOutputStream,
statusCode: Int,
reason: String,
) {
val body = reason.toByteArray(StandardCharsets.UTF_8)
writeHeaders(
output,
statusCode,
reason,
mapOf(
"Content-Type" to "text/plain; charset=utf-8",
"Content-Length" to body.size.toString(),
),
body.size.toLong(),
)
output.write(body)
output.flush()
}
private fun readHeaders(input: BufferedInputStream): Map<String, String> {
val headers = linkedMapOf<String, String>()
repeat(MAX_HEADER_COUNT) {
val line = readAsciiLine(input, MAX_HEADER_LINE)
?: throw IllegalArgumentException("Incomplete request headers")
if (line.isEmpty()) return headers
val separator = line.indexOf(':')
if (separator <= 0) throw IllegalArgumentException("Malformed request header")
headers[line.substring(0, separator).trim()] =
line.substring(separator + 1).trim()
}
throw IllegalArgumentException("Too many request headers")
}
private fun readAsciiLine(input: InputStream, maxLength: Int): String? {
val bytes = ByteArrayOutputStream()
while (bytes.size() <= maxLength) {
val value = input.read()
if (value == -1) {
return if (bytes.size() == 0) null else bytes.toString("ISO-8859-1")
}
if (value == '\n'.code) {
val raw = bytes.toByteArray()
val length = if (raw.isNotEmpty() && raw.last() == '\r'.code.toByte()) {
raw.size - 1
} else {
raw.size
}
return String(raw, 0, length, StandardCharsets.ISO_8859_1)
}
bytes.write(value)
}
throw IllegalArgumentException("HTTP line too long")
}
private fun readLimited(input: InputStream, limit: Int): ByteArray {
val output = ByteArrayOutputStream()
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var total = 0
while (true) {
val count = input.read(buffer)
if (count == -1) break
total += count
require(total <= limit) { "Playlist exceeds size limit" }
output.write(buffer, 0, count)
}
return output.toByteArray()
}
private fun isPlaylist(response: MediaProxyUpstreamResponse): Boolean {
val contentType = getHeader(response.headers, "Content-Type")
?.lowercase(Locale.US)
.orEmpty()
val path = runCatching { URI(response.finalUrl).path.lowercase(Locale.US) }
.getOrDefault("")
return contentType.contains("mpegurl") || path.endsWith(".m3u8")
}
private fun filteredResponseHeaders(input: Map<String, String>): Map<String, String> {
val allowed = setOf(
"accept-ranges",
"cache-control",
"content-length",
"content-range",
"content-type",
"etag",
"expires",
"last-modified",
)
return input.filterKeys { it.lowercase(Locale.US) in allowed }
}
private fun getHeader(headers: Map<String, String>, name: String): String? {
return headers.entries.firstOrNull {
it.key.equals(name, ignoreCase = true)
}?.value
}
private fun defaultReason(statusCode: Int): String = when (statusCode) {
200 -> "OK"
204 -> "No Content"
206 -> "Partial Content"
400 -> "Bad Request"
404 -> "Not Found"
405 -> "Method Not Allowed"
416 -> "Range Not Satisfiable"
502 -> "Bad Gateway"
else -> "Response"
}
override fun close() {
if (!closed.compareAndSet(false, true)) return
running.set(false)
runCatching { serverSocket?.close() }
workers.shutdownNow()
}
companion object {
private const val LOOPBACK_HOST = "127.0.0.1"
private const val MAX_REQUEST_LINE = 8_192
private const val MAX_HEADER_LINE = 8_192
private const val MAX_HEADER_COUNT = 64
private const val MAX_PLAYLIST_BYTES = 5 * 1024 * 1024
}
}

View file

@ -0,0 +1,155 @@
package com.movix.app.proxy
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
internal data class MediaProxyTarget(
val upstreamUrl: String,
val method: String,
val headers: Map<String, String>,
)
internal class MediaProxySessionStore(
private val processSecret: String = randomToken(),
private val now: () -> Long = System::currentTimeMillis,
private val tokenFactory: () -> String = ::randomToken,
private val idleTtlMs: Long = DEFAULT_IDLE_TTL_MS,
private val maxSessions: Int = DEFAULT_MAX_SESSIONS,
private val maxResourcesPerSession: Int = DEFAULT_MAX_RESOURCES,
) {
private data class Session(
val headers: Map<String, String>,
val resources: LinkedHashMap<String, MediaProxyTarget> = linkedMapOf(),
val resourceIdsByUrl: MutableMap<String, String> = mutableMapOf(),
var lastAccessAt: Long,
)
private val lock = Any()
private val sessions = linkedMapOf<String, Session>()
fun create(
upstreamUrl: String,
method: String,
headers: Map<String, String>,
port: Int,
): String = synchronized(lock) {
cleanupExpiredLocked()
while (sessions.size >= maxSessions) {
val oldestId = sessions.minByOrNull { it.value.lastAccessAt }?.key ?: break
sessions.remove(oldestId)
}
val sessionId = uniqueSessionIdLocked()
val resourceId = tokenFactory()
val normalizedMethod = method.uppercase()
val copiedHeaders = headers.toMap()
val root = MediaProxyTarget(upstreamUrl, normalizedMethod, copiedHeaders)
val session = Session(headers = copiedHeaders, lastAccessAt = now())
session.resources[resourceId] = root
session.resourceIdsByUrl[upstreamUrl] = resourceId
sessions[sessionId] = session
MediaProxyPolicy.buildLoopbackUrl(
port,
processSecret,
sessionId,
resourceId,
)
}
fun register(
sessionId: String,
upstreamUrl: String,
port: Int,
): String = synchronized(lock) {
cleanupExpiredLocked()
val session = sessions[sessionId]
?: throw IllegalArgumentException("Unknown media proxy session")
session.lastAccessAt = now()
val existingId = session.resourceIdsByUrl[upstreamUrl]
if (existingId != null) {
return@synchronized MediaProxyPolicy.buildLoopbackUrl(
port,
processSecret,
sessionId,
existingId,
)
}
require(session.resources.size < maxResourcesPerSession) {
"Media proxy session resource limit reached"
}
val resourceId = uniqueResourceIdLocked(session)
session.resources[resourceId] = MediaProxyTarget(
upstreamUrl = upstreamUrl,
method = "GET",
headers = session.headers,
)
session.resourceIdsByUrl[upstreamUrl] = resourceId
MediaProxyPolicy.buildLoopbackUrl(
port,
processSecret,
sessionId,
resourceId,
)
}
fun resolve(
suppliedSecret: String,
sessionId: String,
resourceId: String,
): MediaProxyTarget? = synchronized(lock) {
if (!constantTimeEquals(processSecret, suppliedSecret)) return@synchronized null
cleanupExpiredLocked()
val session = sessions[sessionId] ?: return@synchronized null
val target = session.resources[resourceId] ?: return@synchronized null
session.lastAccessAt = now()
target
}
private fun cleanupExpiredLocked() {
val cutoff = now() - idleTtlMs
val iterator = sessions.iterator()
while (iterator.hasNext()) {
if (iterator.next().value.lastAccessAt < cutoff) {
iterator.remove()
}
}
}
private fun uniqueSessionIdLocked(): String {
repeat(8) {
val candidate = tokenFactory()
if (!sessions.containsKey(candidate)) return candidate
}
throw IllegalStateException("Unable to allocate media proxy session")
}
private fun uniqueResourceIdLocked(session: Session): String {
repeat(8) {
val candidate = tokenFactory()
if (!session.resources.containsKey(candidate)) return candidate
}
throw IllegalStateException("Unable to allocate media proxy resource")
}
companion object {
private const val DEFAULT_IDLE_TTL_MS = 30L * 60L * 1_000L
private const val DEFAULT_MAX_SESSIONS = 512
private const val DEFAULT_MAX_RESOURCES = 4_096
private val secureRandom = SecureRandom()
private fun randomToken(): String {
val bytes = ByteArray(18)
secureRandom.nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun constantTimeEquals(expected: String, supplied: String): Boolean {
return MessageDigest.isEqual(
expected.toByteArray(Charsets.UTF_8),
supplied.toByteArray(Charsets.UTF_8),
)
}
}
}

View file

@ -1,8 +1,10 @@
package com.movix.app.update
import android.app.DownloadManager
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.os.Build
@ -99,10 +101,18 @@ class UpdateModule(private val reactContext: ReactApplicationContext) :
val dm = reactContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val dir = reactContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
?.canonicalFile
?: throw IllegalStateException("External files dir unavailable")
if (!dir.exists()) dir.mkdirs()
val target = File(dir, fileName)
val target = File(dir, fileName).canonicalFile
if (
target.parentFile != dir ||
!target.extension.equals("apk", ignoreCase = true)
) {
promise.reject("INVALID_APK_PATH", "Invalid APK destination")
return
}
if (target.exists()) target.delete() // avoid stale leftovers of same name
val request = DownloadManager.Request(parsed)
@ -192,7 +202,7 @@ class UpdateModule(private val reactContext: ReactApplicationContext) :
try {
val file = File(filePath)
if (!file.exists()) {
promise.reject("FILE_NOT_FOUND", "File does not exist: $filePath")
promise.reject("FILE_NOT_FOUND", "File does not exist")
return
}
val md = MessageDigest.getInstance("SHA-256")
@ -216,21 +226,55 @@ class UpdateModule(private val reactContext: ReactApplicationContext) :
@ReactMethod
fun installApk(filePath: String, promise: Promise) {
try {
val file = File(filePath)
if (!file.exists()) {
promise.reject("FILE_NOT_FOUND", "APK not found: $filePath")
val updateDir = reactContext
.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
?.canonicalFile
?: throw IllegalStateException("External files dir unavailable")
val file = File(filePath).canonicalFile
if (!file.exists() || !file.isFile) {
promise.reject("FILE_NOT_FOUND", "APK file not found")
return
}
if (
file.parentFile != updateDir ||
!file.extension.equals("apk", ignoreCase = true)
) {
promise.reject("INVALID_APK_PATH", "Invalid APK path")
return
}
val authority = "${reactContext.packageName}.updateprovider"
val uri: Uri = FileProvider.getUriForFile(reactContext, authority, file)
val intent = Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
clipData = ClipData.newRawUri("Movix update", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val installers = reactContext.packageManager.queryIntentActivities(
intent,
PackageManager.MATCH_DEFAULT_ONLY,
)
if (installers.isEmpty()) {
promise.reject("NO_INSTALLER", "No package installer available")
return
}
installers.forEach {
reactContext.grantUriPermission(
it.activityInfo.packageName,
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
}
reactContext.startActivity(intent)
val activity = currentActivity
if (activity != null) {
activity.startActivity(intent)
} else {
reactContext.startActivity(
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
promise.resolve(null)
} catch (e: Exception) {
promise.reject("INSTALL_ERROR", e.message ?: "unknown", e)

View file

@ -0,0 +1,117 @@
package com.movix.app.proxy
import java.net.InetAddress
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
class MediaProxyPolicyTest {
@Test
fun rewritesRelativeAbsoluteAndQuotedPlaylistUris() {
val input = """
#EXTM3U
video/720.m3u8
https://media.example/absolute.ts
#EXT-X-KEY:METHOD=AES-128,URI="key.bin"
#EXT-X-MEDIA:TYPE=SUBTITLES,URI='subs/fr.vtt'
#EXT-X-MAP:URI="data:application/octet-stream;base64,AA=="
""".trimIndent()
val output = MediaProxyPolicy.rewritePlaylist(
input,
"https://cdn.example/root/master.m3u8",
) { "LOCAL:$it" }
assertTrue(output.contains("LOCAL:https://cdn.example/root/video/720.m3u8"))
assertTrue(output.contains("LOCAL:https://media.example/absolute.ts"))
assertTrue(output.contains("URI=\"LOCAL:https://cdn.example/root/key.bin\""))
assertTrue(output.contains("URI='LOCAL:https://cdn.example/root/subs/fr.vtt'"))
assertTrue(output.contains("URI=\"data:application/octet-stream;base64,AA==\""))
}
@Test
fun validatesOnlyPublicHttpsDestinations() {
val publicResolver = { _: String ->
listOf(InetAddress.getByAddress(byteArrayOf(93, 184.toByte(), 216.toByte(), 34)))
}
val accepted = MediaProxyPolicy.validatePublicHttpsUrl(
"https://cdn.example/video/master.m3u8",
publicResolver,
)
assertEquals("https", accepted.scheme)
assertEquals("cdn.example", accepted.host)
for (url in listOf(
"http://cdn.example/video.ts",
"https://127.0.0.1/video.ts",
"https://10.0.0.8/video.ts",
"https://user:pass@cdn.example/video.ts",
"https://cdn.example:8443/video.ts",
)) {
assertThrows(IllegalArgumentException::class.java) {
MediaProxyPolicy.validatePublicHttpsUrl(url, publicResolver)
}
}
}
@Test
fun rejectsPrivateDnsAnswers() {
val privateResolver = { _: String ->
listOf(InetAddress.getByAddress(byteArrayOf(192.toByte(), 168.toByte(), 1, 25)))
}
assertThrows(IllegalArgumentException::class.java) {
MediaProxyPolicy.validatePublicHttpsUrl(
"https://cdn.example/video.ts",
privateResolver,
)
}
}
@Test
fun sanitizesRequestHeadersWithAnAllowlist() {
val sanitized = MediaProxyPolicy.sanitizeRequestHeaders(
mapOf(
"Origin" to "https://vidzy.org",
"referer" to "https://vidzy.org/",
"Range" to "bytes=0-1023",
"Accept" to "*/*",
"User-Agent" to "Movix",
"Host" to "attacker.invalid",
"Connection" to "keep-alive",
"Cookie" to "secret=value",
"Authorization" to "Bearer secret",
"X-Injected" to "bad\r\nHeader: value",
),
)
assertEquals("https://vidzy.org", sanitized["Origin"])
assertEquals("https://vidzy.org/", sanitized["Referer"])
assertEquals("bytes=0-1023", sanitized["Range"])
assertFalse(sanitized.containsKey("Host"))
assertFalse(sanitized.containsKey("Connection"))
assertFalse(sanitized.containsKey("Cookie"))
assertFalse(sanitized.containsKey("Authorization"))
assertFalse(sanitized.containsKey("X-Injected"))
}
@Test
fun buildsOpaqueLoopbackUrls() {
val localUrl = MediaProxyPolicy.buildLoopbackUrl(
port = 28123,
processSecret = "process-secret",
sessionId = "session-id",
resourceId = "resource-id",
)
assertEquals(
"http://127.0.0.1:28123/p/process-secret/session-id/resource-id",
localUrl,
)
assertFalse(localUrl.contains("vidzy"))
assertFalse(localUrl.contains("m3u8"))
}
}

View file

@ -0,0 +1,97 @@
package com.movix.app.proxy
import java.io.ByteArrayInputStream
import java.net.URI
import java.net.URL
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MediaProxyServerTest {
@Test
fun rewritesPlaylistsAndStreamsNestedMediaLocally() {
val requests = mutableListOf<Pair<MediaProxyTarget, Map<String, String>>>()
val upstream = object : MediaProxyUpstream {
override fun execute(
target: MediaProxyTarget,
localRequestHeaders: Map<String, String>,
): MediaProxyUpstreamResponse {
requests += target to localRequestHeaders
return if (target.upstreamUrl.endsWith("master.m3u8")) {
val playlist = "#EXTM3U\nsegment-001.ts\n"
MediaProxyUpstreamResponse(
statusCode = 200,
statusMessage = "OK",
headers = mapOf(
"Content-Type" to "application/vnd.apple.mpegurl",
"Content-Length" to playlist.toByteArray().size.toString(),
),
body = ByteArrayInputStream(playlist.toByteArray()),
finalUrl = target.upstreamUrl,
)
} else {
val bytes = byteArrayOf(10, 20, 30, 40)
MediaProxyUpstreamResponse(
statusCode = 206,
statusMessage = "Partial Content",
headers = mapOf(
"Content-Type" to "video/mp2t",
"Content-Length" to bytes.size.toString(),
"Content-Range" to "bytes 0-3/4",
),
body = ByteArrayInputStream(bytes),
finalUrl = target.upstreamUrl,
)
}
}
}
val server = MediaProxyServer(
upstream = upstream,
validateUrl = { URI(it) },
)
try {
val localMaster = server.open(
upstreamUrl = "https://media.example/root/master.m3u8",
method = "GET",
headers = mapOf(
"Origin" to "https://vidzy.org",
"Referer" to "https://vidzy.org/",
),
)
val playlistConnection = URL(localMaster).openConnection()
val playlist = playlistConnection.getInputStream().bufferedReader().readText()
assertEquals("*", playlistConnection.getHeaderField("Access-Control-Allow-Origin"))
assertTrue(playlist.contains("http://127.0.0.1:"))
assertFalse(playlist.contains("media.example"))
val localSegment = playlist.lineSequence()
.first { it.isNotBlank() && !it.startsWith("#") }
val segmentConnection = URL(localSegment).openConnection()
segmentConnection.setRequestProperty("Origin", "https://movix.app")
segmentConnection.setRequestProperty("Referer", "https://movix.app/")
segmentConnection.setRequestProperty("Range", "bytes=0-3")
val bytes = segmentConnection.getInputStream().readBytes()
assertArrayEquals(byteArrayOf(10, 20, 30, 40), bytes)
assertEquals("bytes 0-3/4", segmentConnection.getHeaderField("Content-Range"))
assertEquals(2, requests.size)
assertEquals(
"https://vidzy.org/",
requests[1].first.headers["Referer"],
)
assertEquals(
"https://vidzy.org",
requests[1].first.headers["Origin"],
)
assertEquals("bytes=0-3", requests[1].second["Range"])
assertFalse(requests[1].second.containsKey("Origin"))
assertFalse(requests[1].second.containsKey("Referer"))
} finally {
server.close()
}
}
}

View file

@ -0,0 +1,87 @@
package com.movix.app.proxy
import java.net.URI
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class MediaProxySessionStoreTest {
@Test
fun createsOpaqueUrlsAndResolvesRegisteredResources() {
val tokens = ArrayDeque(
listOf(
"session_token_0001",
"resource_token_001",
"resource_token_002",
),
)
val store = MediaProxySessionStore(
processSecret = "process_secret_01",
now = { 1_000L },
tokenFactory = { tokens.removeFirst() },
)
val rootLocalUrl = store.create(
upstreamUrl = "https://u14.vidzy.cc/movie/master.m3u8?token=secret",
method = "GET",
headers = mapOf("Referer" to "https://vidzy.org/"),
port = 28_123,
)
assertTrue(rootLocalUrl.startsWith("http://127.0.0.1:28123/p/"))
assertFalse(rootLocalUrl.contains("vidzy"))
assertFalse(rootLocalUrl.contains("token=secret"))
val rootPath = URI(rootLocalUrl).path.split('/').filter(String::isNotEmpty)
val root = store.resolve(
suppliedSecret = rootPath[1],
sessionId = rootPath[2],
resourceId = rootPath[3],
)
assertEquals(
"https://u14.vidzy.cc/movie/master.m3u8?token=secret",
root?.upstreamUrl,
)
assertEquals("GET", root?.method)
assertEquals("https://vidzy.org/", root?.headers?.get("Referer"))
val nestedLocalUrl = store.register(
sessionId = rootPath[2],
upstreamUrl = "https://u14.vidzy.cc/movie/segment.ts",
port = 28_123,
)
val nestedPath = URI(nestedLocalUrl).path.split('/').filter(String::isNotEmpty)
val nested = store.resolve(
suppliedSecret = nestedPath[1],
sessionId = nestedPath[2],
resourceId = nestedPath[3],
)
assertEquals("https://u14.vidzy.cc/movie/segment.ts", nested?.upstreamUrl)
assertEquals("GET", nested?.method)
}
@Test
fun expiresIdleSessionsAndRejectsWrongSecrets() {
var now = 5_000L
val tokens = ArrayDeque(listOf("session_token_0002", "resource_token_003"))
val store = MediaProxySessionStore(
processSecret = "process_secret_02",
now = { now },
tokenFactory = { tokens.removeFirst() },
idleTtlMs = 1_000L,
)
val localUrl = store.create(
upstreamUrl = "https://r1.fsvid.lol/movie/master.m3u8",
method = "GET",
headers = emptyMap(),
port = 28_124,
)
val path = URI(localUrl).path.split('/').filter(String::isNotEmpty)
assertNull(store.resolve("wrong_secret_000", path[2], path[3]))
now += 1_001L
assertNull(store.resolve(path[1], path[2], path[3]))
}
}

View file

@ -1,6 +1,6 @@
{
"name": "Movix",
"displayName": "Movix",
"version": "2.0.0",
"buildNumber": "1"
"version": "2.5.4",
"buildNumber": "13"
}

Binary file not shown.

View file

@ -4,12 +4,11 @@ import React, {
useImperativeHandle,
useRef,
} from 'react';
import { Linking, Platform } from 'react-native';
import { Platform } from 'react-native';
import { WebView, type WebViewNavigation } from 'react-native-webview';
import type {
WebViewErrorEvent,
WebViewMessageEvent,
ShouldStartLoadRequest,
} from 'react-native-webview/lib/WebViewTypes';
import { handleBridgeMessage } from '../services/bridge';
import { buildInjectedJavaScript } from '../injection/inject';
@ -27,13 +26,12 @@ interface WebViewBrowserProps {
url: string;
onNavigationStateChange?: (state: WebViewNavigation) => void;
onError?: (error: string) => void;
onLoadEnd?: () => void;
}
const injectedJS = buildInjectedJavaScript();
const WebViewBrowser = forwardRef<WebViewBrowserRef, WebViewBrowserProps>(
({ url, onNavigationStateChange, onError, onLoadEnd }, ref) => {
({ url, onNavigationStateChange, onError }, ref) => {
const webViewRef = useRef<WebView>(null);
useImperativeHandle(ref, () => ({
@ -63,27 +61,6 @@ const WebViewBrowser = forwardRef<WebViewBrowserRef, WebViewBrowserProps>(
[onError],
);
const onShouldStartLoadWithRequest = useCallback(
(request: ShouldStartLoadRequest) => {
const { url, navigationType } = request;
if (
url.startsWith('https://') ||
url.startsWith('http://') ||
url.startsWith('about:') ||
url.startsWith('blob:')
) {
return true;
}
// Ouvre uniquement les deep links déclenchés par un vrai clic utilisateur.
// Les redirections automatiques (pubs, iframes) sont silencieusement bloquées.
if (navigationType === 'click') {
Linking.openURL(url).catch(() => {});
}
return false;
},
[],
);
const onWebViewError = useCallback(
(event: WebViewErrorEvent) => {
onError?.(event.nativeEvent.description);
@ -106,12 +83,10 @@ const WebViewBrowser = forwardRef<WebViewBrowserRef, WebViewBrowserProps>(
// Bridge messages
onMessage={onMessage}
// Navigation
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
onNavigationStateChange={onNavigationStateChange}
// Errors
onError={onWebViewError}
onHttpError={onHttpError}
onLoadEnd={onLoadEnd}
// Config
userAgent={userAgent}
javaScriptEnabled={true}
@ -121,7 +96,7 @@ const WebViewBrowser = forwardRef<WebViewBrowserRef, WebViewBrowserProps>(
allowsFullscreenVideo={true}
allowsBackForwardNavigationGestures={true}
// Sécurité
originWhitelist={['https://*', 'http://*', 'about:*', 'blob:*']}
originWhitelist={['https://*', 'http://*']}
mixedContentMode="compatibility"
// Cache
cacheEnabled={true}

View file

@ -1,5 +1,5 @@
export const CONFIG = {
SITE_URL: 'https://movix.cash',
SITE_URL: 'https://movix.tax',
DNS_PRIMARY: '1.1.1.1',
DNS_SECONDARY: '1.0.0.1',
DNS_DOH_URL: 'https://cloudflare-dns.com/dns-query',
@ -19,7 +19,7 @@ export const UPDATE_CHECK = {
};
export const FALLBACK_CONFIG = {
PRIMARY_URL: 'https://movix.cash',
GITHUB_URL: 'https://github.com/movixcorp/MovixOpenSource',
PRIMARY_URL: 'https://movix.tax',
GITHUB_URL: 'https://github.com/Movix-STMG/MovixOpenSource',
TELEGRAM_URL: 'https://t.me/movix_site',
};

View file

@ -0,0 +1,53 @@
export type UpdateForegroundAction =
| 'none'
| 'continue_after_permission'
| 'installed'
| 'install_not_completed';
type UpdateForegroundInput = {
stage: string;
installPermissionGranted: boolean;
localBuildNumber: number;
targetBuildNumber: number;
};
type PendingApkCandidate = {
targetBuildNumber: number;
targetSha256: string;
apkFilePath: string;
};
export function decideUpdateForegroundAction({
stage,
installPermissionGranted,
localBuildNumber,
targetBuildNumber,
}: UpdateForegroundInput): UpdateForegroundAction {
if (stage === 'need_permission') {
return installPermissionGranted && localBuildNumber < targetBuildNumber
? 'continue_after_permission'
: 'none';
}
if (stage === 'installing') {
return localBuildNumber >= targetBuildNumber
? 'installed'
: 'install_not_completed';
}
return 'none';
}
export function canReusePendingApk(
pending: PendingApkCandidate | null,
targetBuildNumber: number,
): boolean {
if (!pending || pending.targetBuildNumber !== targetBuildNumber) {
return false;
}
return (
/^[a-f0-9]{64}$/i.test(pending.targetSha256) &&
/\.apk$/i.test(pending.apkFilePath.trim())
);
}

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
import { AppState, type AppStateStatus, Platform } from 'react-native';
import { UPDATE_CHECK } from '../config';
import {
@ -18,6 +18,10 @@ import {
type DownloadProgress,
} from '../services/updateDownloader';
import { fetchLatestVersion, type Manifest } from '../services/versionCheck';
import {
canReusePendingApk,
decideUpdateForegroundAction,
} from './updateResume';
export type UpdateStage =
| 'idle'
@ -67,6 +71,23 @@ function fileNameForBuild(buildNumber: number): string {
export function useAppUpdate(githubUrl: string | null) {
const [state, setState] = useState<UpdateState>(initialState);
const cancelRef = useRef(false);
const stateRef = useRef(state);
const pendingRef = useRef<PendingDownload | null>(null);
const mountedRef = useRef(true);
const foregroundBusyRef = useRef(false);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
const pollGenerationRef = useRef(0);
stateRef.current = state;
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
cancelRef.current = true;
pollGenerationRef.current += 1;
};
}, []);
// --- On mount: reconcile local / pending DL / fresh manifest -----------
// Cases handled:
@ -78,6 +99,7 @@ export function useAppUpdate(githubUrl: string | null) {
useEffect(() => {
if (Platform.OS !== 'android') return;
let cancelled = false;
cancelRef.current = false;
(async () => {
try {
@ -91,8 +113,10 @@ export function useAppUpdate(githubUrl: string | null) {
if (raw) {
try {
pending = JSON.parse(raw) as PendingDownload;
pendingRef.current = pending;
} catch {
await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
pendingRef.current = null;
}
}
@ -103,6 +127,7 @@ export function useAppUpdate(githubUrl: string | null) {
} catch {}
await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
pending = null;
pendingRef.current = null;
}
if (!githubUrl) return; // wait until address config resolves
@ -119,6 +144,7 @@ export function useAppUpdate(githubUrl: string | null) {
} catch {}
await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
pending = null;
pendingRef.current = null;
}
// Case C: pending for same target as manifest — query DL.
@ -156,6 +182,7 @@ export function useAppUpdate(githubUrl: string | null) {
}
// DL failed / query errored / unknown → clear, fall through to offer fresh.
await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
pendingRef.current = null;
}
// No (valid) pending → offer the fresh manifest.
@ -195,6 +222,7 @@ export function useAppUpdate(githubUrl: string | null) {
}
} catch {}
await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
pendingRef.current = null;
}
// Case E: idle, stay silent.
} catch (err) {
@ -209,6 +237,22 @@ export function useAppUpdate(githubUrl: string | null) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [githubUrl]);
useEffect(() => {
if (Platform.OS !== 'android') return;
const subscription = AppState.addEventListener('change', nextState => {
const previousState = appStateRef.current;
appStateRef.current = nextState;
if (previousState !== 'active' && nextState === 'active') {
void handleForegroundResume();
}
});
return () => subscription.remove();
// The handler reads current data through refs, so this subscription stays stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// --- Actions ---------------------------------------------------------
const dismiss = useCallback(() => {
@ -231,8 +275,60 @@ export function useAppUpdate(githubUrl: string | null) {
return;
}
await beginDownload(manifest);
}, [state.manifest]);
const cancel = useCallback(async () => {
if (state.downloadId == null) return;
// Leave cancelRef.current = true until a new accept() starts a fresh DL.
// Any in-flight pollUntilDone promise will see the flag and bail out in its .then.
cancelRef.current = true;
pollGenerationRef.current += 1;
try {
await cancelDownload(state.downloadId);
} catch (err) {
console.warn('[useAppUpdate] cancel failed', err);
}
await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
pendingRef.current = null;
setState({ ...initialState });
}, [state.downloadId]);
const openSettings = useCallback(async () => {
try {
await openInstallSettings();
} catch (err) {
console.warn('[useAppUpdate] openInstallSettings failed', err);
}
}, []);
const retry = useCallback(() => {
const current = stateRef.current;
const pending = pendingRef.current;
if (
current.error === 'install_denied' &&
current.manifest &&
pending &&
canReusePendingApk(pending, current.manifest.buildNumber)
) {
cancelRef.current = false;
void verifyAndInstall(pending);
return;
}
setState(s => ({
...s,
stage: s.manifest ? 'offered' : 'idle',
error: null,
}));
}, []);
// --- Internal helpers -------------------------------------------------
async function beginDownload(manifest: Manifest) {
// Fresh download — release any cancel lock from a previous session.
cancelRef.current = false;
pollGenerationRef.current += 1;
try {
const fileName = fileNameForBuild(manifest.buildNumber);
@ -251,11 +347,13 @@ export function useAppUpdate(githubUrl: string | null) {
apkFilePath: filePath,
startedAt: new Date().toISOString(),
};
pendingRef.current = pending;
await AsyncStorage.setItem(
UPDATE_CHECK.PENDING_DOWNLOAD_KEY,
JSON.stringify(pending),
);
if (!mountedRef.current) return;
setState({
stage: 'downloading',
manifest,
@ -267,50 +365,35 @@ export function useAppUpdate(githubUrl: string | null) {
resumeProgressLoop(pending);
} catch (err) {
console.warn('[useAppUpdate] enqueue failed', err);
setState(s => ({ ...s, stage: 'error', error: 'network' }));
if (mountedRef.current) {
setState(s => ({ ...s, stage: 'error', error: 'network' }));
}
}
}, [state.manifest]);
const cancel = useCallback(async () => {
if (state.downloadId == null) return;
// Leave cancelRef.current = true until a new accept() starts a fresh DL.
// Any in-flight pollUntilDone promise will see the flag and bail out in its .then.
cancelRef.current = true;
try {
await cancelDownload(state.downloadId);
} catch (err) {
console.warn('[useAppUpdate] cancel failed', err);
}
await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
setState({ ...initialState });
}, [state.downloadId]);
const openSettings = useCallback(async () => {
try {
await openInstallSettings();
} catch (err) {
console.warn('[useAppUpdate] openInstallSettings failed', err);
}
}, []);
const retry = useCallback(() => {
setState(s => ({ ...s, stage: s.manifest ? 'offered' : 'idle', error: null }));
}, []);
// --- Internal helpers -------------------------------------------------
}
function resumeProgressLoop(pending: PendingDownload) {
const generation = ++pollGenerationRef.current;
pollUntilDone(
pending.downloadId,
progress => {
if (generation !== pollGenerationRef.current) return;
setState(s =>
s.stage === 'downloading' ? { ...s, progress } : s,
);
},
() => !cancelRef.current,
() =>
mountedRef.current &&
!cancelRef.current &&
generation === pollGenerationRef.current,
)
.then(final => {
if (cancelRef.current) return;
if (
cancelRef.current ||
!mountedRef.current ||
generation !== pollGenerationRef.current
) {
return;
}
if (final.status === 'successful') {
verifyAndInstall(pending);
} else {
@ -318,12 +401,19 @@ export function useAppUpdate(githubUrl: string | null) {
}
})
.catch(err => {
if (
!mountedRef.current ||
generation !== pollGenerationRef.current
) {
return;
}
console.warn('[useAppUpdate] poll error', err);
setState(s => ({ ...s, stage: 'error', error: 'unknown' }));
});
}
async function verifyAndInstall(pending: PendingDownload) {
pendingRef.current = pending;
setState(s => ({ ...s, stage: 'verifying' }));
try {
const hash = await computeSha256(pending.apkFilePath);
@ -333,6 +423,7 @@ export function useAppUpdate(githubUrl: string | null) {
want: pending.targetSha256,
});
await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
pendingRef.current = null;
setState(s => ({ ...s, stage: 'error', error: 'sha_mismatch' }));
return;
}
@ -353,6 +444,66 @@ export function useAppUpdate(githubUrl: string | null) {
}
}
async function handleForegroundResume() {
if (foregroundBusyRef.current || !mountedRef.current) return;
const current = stateRef.current;
if (
!current.manifest ||
(current.stage !== 'need_permission' && current.stage !== 'installing')
) {
return;
}
foregroundBusyRef.current = true;
try {
const [installPermissionGranted, localBuildNumber] = await Promise.all([
canInstallApks(),
getLocalVersionCode().catch(() => 0),
]);
if (
!mountedRef.current ||
stateRef.current.stage !== current.stage
) {
return;
}
const action = decideUpdateForegroundAction({
stage: current.stage,
installPermissionGranted,
localBuildNumber,
targetBuildNumber: current.manifest.buildNumber,
});
if (action === 'continue_after_permission') {
await beginDownload(current.manifest);
return;
}
if (action === 'installed') {
await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
pendingRef.current = null;
setState({ ...initialState });
return;
}
if (action === 'install_not_completed') {
setState(s => ({
...s,
stage: 'error',
error: 'install_denied',
}));
}
} catch (err) {
console.warn('[useAppUpdate] foreground reconciliation failed', err);
if (mountedRef.current) {
setState(s => ({ ...s, stage: 'error', error: 'unknown' }));
}
} finally {
foregroundBusyRef.current = false;
}
}
return {
state,
dismiss,

View file

@ -1,3 +1,5 @@
import { MEDIA_ENTRY_PATH_SOURCE } from './mediaProxyRouting';
/**
* Runtime bridge injecté dans le WebView AVANT le chargement de la page.
*
@ -18,6 +20,9 @@ export function buildBridgeRuntime(): string {
// --- Pending requests ---
var _pendingRequests = {};
var _requestCounter = 0;
var _nativeWindowFetch =
typeof window.fetch === 'function' ? window.fetch.bind(window) : null;
var _mediaEntryPath = new RegExp(${JSON.stringify(MEDIA_ENTRY_PATH_SOURCE)}, 'i');
function generateId() {
return 'req_' + (++_requestCounter) + '_' + Date.now();
@ -67,8 +72,78 @@ export function buildBridgeRuntime(): string {
return bytes.buffer;
}
function isLocalMediaProxyCandidate(details) {
var method = String(details.method || 'GET').toUpperCase();
var url = String(details.url || '').trim();
if (method !== 'GET' && method !== 'HEAD') return false;
if (!/^https:\\/\\//i.test(url)) return false;
if (/^https:\\/\\/(?:127\\.0\\.0\\.1|localhost)(?::|\\/)/i.test(url)) {
return false;
}
if (!_mediaEntryPath.test(url)) return false;
var headers = details.headers || {};
return Object.keys(headers).some(function(key) {
return /^(?:origin|referer|range)$/i.test(key);
});
}
function responseHeadersToString(headers) {
var headersStr = '';
if (headers && typeof headers.forEach === 'function') {
headers.forEach(function(value, key) {
headersStr += key + ': ' + value + '\\r\\n';
});
}
return headersStr;
}
async function tryLocalMediaProxy(details) {
if (!_nativeWindowFetch) {
throw new Error('Native WebView fetch unavailable');
}
var openResponse = await bridgeRequest({
type: 'GM_OPEN_MEDIA_PROXY',
url: details.url,
method: (details.method || 'GET').toUpperCase(),
headers: details.headers || {}
});
if (!openResponse.success || typeof openResponse.value !== 'string') {
throw new Error(openResponse.error || 'Local media proxy unavailable');
}
var localHeaders = {};
var originalHeaders = details.headers || {};
for (var key in originalHeaders) {
if (/^(?:accept|range)$/i.test(key)) {
localHeaders[key] = originalHeaders[key];
}
}
var response = await _nativeWindowFetch(openResponse.value, {
method: (details.method || 'GET').toUpperCase(),
headers: localHeaders
});
var responseBody;
if (details.responseType === 'arraybuffer') {
responseBody = await response.arrayBuffer();
} else {
responseBody = await response.text();
}
return {
status: response.status || 0,
statusText: response.statusText || '',
responseHeaders: responseHeadersToString(response.headers),
response: responseBody,
responseText: typeof responseBody === 'string' ? responseBody : '',
finalUrl: details.url
};
}
// --- GM_xmlhttpRequest ---
function GM_xmlhttpRequest(details) {
function sendBridgeRequest(details) {
var headers = details.headers || {};
var bodyStr = null;
@ -135,6 +210,34 @@ export function buildBridgeRuntime(): string {
return { abort: function() {} };
}
function GM_xmlhttpRequest(details) {
if (!isLocalMediaProxyCandidate(details)) {
return sendBridgeRequest(details);
}
var cancelled = false;
Promise.resolve()
.then(function() {
return tryLocalMediaProxy(details);
})
.then(function(response) {
if (!cancelled && details.onload) {
details.onload(response);
}
})
.catch(function() {
if (!cancelled) {
sendBridgeRequest(details);
}
});
return {
abort: function() {
cancelled = true;
}
};
}
// --- GM_getValue / GM_setValue / GM_deleteValue ---
// Version synchrone avec cache local + sync async vers le natif
var _storageCache = {};

View file

@ -0,0 +1,29 @@
export interface MediaProxyCandidate {
url?: string;
method?: string;
headers?: Record<string, string>;
responseType?: string;
}
export const MEDIA_ENTRY_PATH_SOURCE =
String.raw`\.(?:m3u8|mp4|m4v|m4s|mpd|ts|aac|m4a|vtt|srt)(?:$|[?#])`;
const MEDIA_ENTRY_PATH = new RegExp(MEDIA_ENTRY_PATH_SOURCE, 'i');
export function isLocalMediaProxyCandidate(
details: MediaProxyCandidate,
): boolean {
const method = String(details.method || 'GET').toUpperCase();
const url = String(details.url || '').trim();
if (method !== 'GET' && method !== 'HEAD') return false;
if (!/^https:\/\//i.test(url)) return false;
if (/^https:\/\/(?:127\.0\.0\.1|localhost)(?::|\/)/i.test(url)) {
return false;
}
if (!MEDIA_ENTRY_PATH.test(url)) return false;
return Object.keys(details.headers || {}).some(key =>
/^(?:origin|referer|range)$/i.test(key),
);
}

View file

@ -7,8 +7,7 @@ import {
Platform,
Modal,
TouchableOpacity,
Image,
Animated,
ActivityIndicator,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { WebViewNavigation } from 'react-native-webview';
@ -45,8 +44,6 @@ export default function BrowserScreen() {
const [currentUrl, setCurrentUrl] = useState('');
const [dnsEnabled, setDnsEnabled] = useState(false);
const [settingsVisible, setSettingsVisible] = useState(false);
const [webViewReady, setWebViewReady] = useState(false);
const splashFade = useRef(new Animated.Value(1)).current;
const activeUrl = urlChain[mirrorIndex] ?? '';
@ -98,15 +95,6 @@ export default function BrowserScreen() {
[activeUrl, mirrorIndex, urlChain.length],
);
const onWebViewLoadEnd = useCallback(() => {
if (webViewReady) return;
Animated.timing(splashFade, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}).start(() => setWebViewReady(true));
}, [webViewReady, splashFade]);
const closeSettings = useCallback(() => {
setSettingsVisible(false);
AsyncStorage.getItem('dns_enabled').then(val => {
@ -117,34 +105,36 @@ export default function BrowserScreen() {
const onRetry = useCallback(async () => {
setAllMirrorsFailed(false);
setMirrorIndex(0);
setWebViewReady(false);
splashFade.setValue(1);
await refresh();
}, [refresh, splashFade]);
}, [refresh]);
const showWebView = !isLoading && !!config && !allMirrorsFailed;
const showSplash = (!webViewReady || isLoading || !config) && !allMirrorsFailed;
if (isLoading || !config) {
return (
<View style={[styles.container, styles.centered, { paddingTop: insets.top }]}>
<ActivityIndicator size="large" color="#8b5cf6" />
</View>
);
}
if (allMirrorsFailed) {
return (
<MirrorErrorScreen telegramUrl={config.telegramUrl} onRetry={onRetry} />
);
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{showWebView && (
<View style={styles.webViewContainer}>
<WebViewBrowser
key={activeUrl}
ref={webViewRef}
url={activeUrl}
onNavigationStateChange={onNavigationStateChange}
onError={onWebViewError}
onLoadEnd={onWebViewLoadEnd}
/>
</View>
)}
<View style={styles.webViewContainer}>
<WebViewBrowser
key={activeUrl}
ref={webViewRef}
url={activeUrl}
onNavigationStateChange={onNavigationStateChange}
onError={onWebViewError}
/>
</View>
{allMirrorsFailed && config && (
<MirrorErrorScreen telegramUrl={config.telegramUrl} onRetry={onRetry} />
)}
{!toolbarHidden && showWebView && (
{!toolbarHidden && (
<View style={{ paddingBottom: insets.bottom }}>
<BrowserToolbar
canGoBack={canGoBack}
@ -163,39 +153,23 @@ export default function BrowserScreen() {
</View>
)}
{showWebView && (
<Modal
visible={settingsVisible}
animationType="slide"
onRequestClose={closeSettings}>
<View style={[styles.modalContainer, { paddingTop: insets.top }]}>
<View style={styles.modalHeader}>
<TouchableOpacity onPress={closeSettings} style={styles.closeButton}>
<Text style={styles.closeText}>Fermer</Text>
</TouchableOpacity>
<Text style={styles.modalTitle}>Paramètres</Text>
<View style={styles.closeButton} />
</View>
<SettingsScreen />
<Modal
visible={settingsVisible}
animationType="slide"
onRequestClose={closeSettings}>
<View style={[styles.modalContainer, { paddingTop: insets.top }]}>
<View style={styles.modalHeader}>
<TouchableOpacity onPress={closeSettings} style={styles.closeButton}>
<Text style={styles.closeText}>Fermer</Text>
</TouchableOpacity>
<Text style={styles.modalTitle}>Paramètres</Text>
<View style={styles.closeButton} />
</View>
</Modal>
)}
<SettingsScreen />
</View>
</Modal>
{navBarHidden && showWebView && (
<MiniPill onPress={() => setSettingsVisible(true)} />
)}
{showSplash && (
<Animated.View
style={[StyleSheet.absoluteFillObject, styles.splash, { opacity: splashFade }]}
pointerEvents="none">
<Image
source={require('../../assets/movix512.png')}
style={styles.splashLogo}
resizeMode="contain"
/>
</Animated.View>
)}
{navBarHidden && <MiniPill onPress={() => setSettingsVisible(true)} />}
</View>
);
}
@ -205,6 +179,10 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: '#0a0a0a',
},
centered: {
justifyContent: 'center',
alignItems: 'center',
},
webViewContainer: {
flex: 1,
},
@ -235,13 +213,4 @@ const styles = StyleSheet.create({
fontSize: 15,
fontWeight: '500',
},
splash: {
backgroundColor: '#B5302C',
justifyContent: 'center',
alignItems: 'center',
},
splashLogo: {
width: 150,
height: 150,
},
});

View file

@ -20,7 +20,7 @@ import { getLocalVersionName } from '../services/apkInstaller';
const { DnsModule } = NativeModules;
const M3U8_KEYS = ['voe','fsvid','vidzy','vidmoly','sibnet','uqload','doodstream','seekstreaming'] as const;
const LIVETV_KEYS = ['linkzy','wiflix','sosplay','livetv','matches'] as const;
const LIVETV_KEYS = ['northlive','wiflix','sosplay','livetv','matches'] as const;
type ExtractionPrefs = {
version: 1;

View file

@ -48,7 +48,7 @@ function errorLabel(error: UpdateError | null): string {
case 'disk':
return 'Espace insuffisant sur l\'appareil';
case 'install_denied':
return 'Installation refusée ou bloquée';
return 'Installation annulée ou bloquée. Tu peux réessayer sans retélécharger.';
case 'unknown':
default:
return 'Erreur inattendue';

View file

@ -7,6 +7,7 @@
*/
import { type RefObject } from 'react';
import { NativeModules } from 'react-native';
import type WebView from 'react-native-webview';
import {
getCurrentDeviceName,
@ -177,7 +178,12 @@ async function handleCastShimMessage(
export interface BridgeRequest {
id: string;
type: 'GM_FETCH' | 'GM_GET_VALUE' | 'GM_SET_VALUE' | 'GM_DELETE_VALUE';
type:
| 'GM_FETCH'
| 'GM_OPEN_MEDIA_PROXY'
| 'GM_GET_VALUE'
| 'GM_SET_VALUE'
| 'GM_DELETE_VALUE';
url?: string;
method?: string;
headers?: Record<string, string>;
@ -204,12 +210,78 @@ const storage = new Map<string, any>();
function parseResponseHeaders(headers: Headers): Record<string, string> {
const result: Record<string, string> = {};
headers.forEach((value, key) => {
headers.forEach((value: string, key: string) => {
result[key.toLowerCase()] = value;
});
return result;
}
interface MediaProxyNativeModule {
open: (
url: string,
method: string,
headers: Record<string, string>,
) => Promise<string>;
}
async function handleGMOpenMediaProxy(
req: BridgeRequest,
): Promise<BridgeResponse> {
const method = String(req.method || 'GET').toUpperCase();
if (
!req.url ||
!/^https:\/\//i.test(req.url) ||
(method !== 'GET' && method !== 'HEAD')
) {
return {
id: req.id,
success: false,
error: 'Invalid local media proxy request',
};
}
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers || {}).slice(0, 32)) {
if (
key.length <= 128 &&
value.length <= 8192 &&
!/[\r\n]/.test(key) &&
!/[\r\n]/.test(value)
) {
headers[key] = value;
}
}
const mediaProxy = NativeModules.MediaProxy as
| MediaProxyNativeModule
| undefined;
if (!mediaProxy?.open) {
return {
id: req.id,
success: false,
error: 'Local media proxy unavailable',
};
}
try {
const localUrl = await mediaProxy.open(req.url, method, headers);
if (!/^http:\/\/127\.0\.0\.1:\d+\/p\//i.test(localUrl)) {
throw new Error('Invalid loopback response');
}
return {
id: req.id,
success: true,
value: localUrl,
};
} catch {
return {
id: req.id,
success: false,
error: 'Local media proxy unavailable',
};
}
}
const HEADER_RULES: Array<{ match: RegExp; headers: Record<string, string> }> = [
{
match: /fsvid\.lol/i,
@ -381,6 +453,9 @@ export async function handleBridgeMessage(
let response: BridgeResponse;
switch (req.type) {
case 'GM_OPEN_MEDIA_PROXY':
response = await handleGMOpenMediaProxy(req);
break;
case 'GM_FETCH':
response = await handleGMFetch(req);
break;

View file

@ -1,4 +1,4 @@
import { DeviceEventEmitter, NativeModules, Platform } from 'react-native';
import { DeviceEventEmitter, NativeModules } from 'react-native';
export type CastSessionState = 'idle' | 'starting' | 'connected' | 'ending';
@ -29,7 +29,6 @@ function ensureModule(): CastModuleType {
}
export async function isCastSupported(): Promise<boolean> {
if (Platform.OS !== 'android') return false;
try {
return await ensureModule().isSupported();
} catch (err) {

View file

@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
const root = new URL('../', import.meta.url);
async function read(relativePath) {
return readFile(new URL(relativePath, root), 'utf8');
}
test('TypeScript bridge exposes the GM_OPEN_MEDIA_PROXY native contract', async () => {
const bridge = await read('src/services/bridge.ts');
assert.match(bridge, /GM_OPEN_MEDIA_PROXY/);
assert.match(bridge, /NativeModules\.MediaProxy/);
assert.match(bridge, /\.open\s*\(\s*req\.url/);
assert.match(bridge, /case\s+['"]GM_FETCH['"]/);
});
test('Android registers the MediaProxy native package', async () => {
const application = await read(
'android/app/src/main/java/com/movix/app/MainApplication.kt',
);
const packageSource = await read(
'android/app/src/main/java/com/movix/app/proxy/MediaProxyPackage.kt',
);
const moduleSource = await read(
'android/app/src/main/java/com/movix/app/proxy/MediaProxyModule.kt',
);
assert.match(application, /add\(MediaProxyPackage\(\)\)/);
assert.match(packageSource, /MediaProxyModule\(reactContext\)/);
assert.match(moduleSource, /override fun getName\(\)\s*=\s*"MediaProxy"/);
assert.match(moduleSource, /fun open\(/);
assert.match(moduleSource, /server\.open\(/);
});

View file

@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import ts from 'typescript';
async function loadRoutingModule() {
const sourceUrl = new URL('../src/injection/mediaProxyRouting.ts', import.meta.url);
const source = await readFile(sourceUrl, 'utf8');
const transpiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
fileName: sourceUrl.pathname,
});
const dataUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString('base64')}`;
return import(dataUrl);
}
test('routes protected HLS and direct media entry URLs to the local proxy', async () => {
const { isLocalMediaProxyCandidate } = await loadRoutingModule();
for (const url of [
'https://u14.vidzy.cc/hls/movie/master.m3u8?token=abc',
'https://r1.fsvid.lol/hls/movie/segment.ts',
'https://cdn.example/video/v.mp4?token=abc',
]) {
assert.equal(isLocalMediaProxyCandidate({
url,
method: 'GET',
responseType: 'arraybuffer',
headers: {
Referer: 'https://vidzy.org/',
Origin: 'https://vidzy.org',
},
}), true, url);
}
});
test('matches protected headers case-insensitively', async () => {
const { isLocalMediaProxyCandidate } = await loadRoutingModule();
assert.equal(isLocalMediaProxyCandidate({
url: 'https://free.finepulfe.xyz/movie/master.m3u8',
method: 'get',
responseType: 'arraybuffer',
headers: {
referer: 'https://purstream.mx/',
origin: 'https://purstream.mx',
},
}), true);
});
test('keeps extraction pages, APIs, posts, and unprotected media on GM_FETCH', async () => {
const { isLocalMediaProxyCandidate } = await loadRoutingModule();
const requests = [
{
url: 'https://vidzy.org/embed-id.html',
method: 'GET',
headers: { Referer: 'https://vidzy.org/' },
},
{
url: 'https://api.movix.show/api/purstream/movie/550/stream',
method: 'GET',
headers: { Origin: 'https://movix.show' },
},
{
url: 'https://u14.vidzy.cc/hls/movie/master.m3u8',
method: 'POST',
headers: { Referer: 'https://vidzy.org/' },
},
{
url: 'https://cdn.example/movie/master.m3u8',
method: 'GET',
headers: { Accept: '*/*' },
},
{
url: 'http://127.0.0.1:28123/p/session',
method: 'GET',
headers: { Referer: 'https://vidzy.org/' },
},
];
for (const request of requests) {
assert.equal(isLocalMediaProxyCandidate(request), false, request.url);
}
});

View file

@ -0,0 +1,163 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import vm from 'node:vm';
import ts from 'typescript';
async function loadBridgeRuntimeBuilder() {
const sourceUrl = new URL('../src/injection/bridge-runtime.ts', import.meta.url);
let source = await readFile(sourceUrl, 'utf8');
source = source.replace(
/import\s+\{\s*MEDIA_ENTRY_PATH_SOURCE\s*\}\s+from\s+['"]\.\/mediaProxyRouting['"];\s*/,
`const MEDIA_ENTRY_PATH_SOURCE = ${JSON.stringify(String.raw`\.(?:m3u8|mp4|m4v|m4s|mpd|ts|aac|m4a|vtt|srt)(?:$|[?#])`)};\n`,
);
const transpiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
fileName: sourceUrl.pathname,
});
const dataUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString('base64')}`;
return import(dataUrl);
}
function createRuntimeHarness(buildBridgeRuntime, { rejectOpen = false } = {}) {
const posted = [];
const nativeFetches = [];
const listeners = new Map();
class CustomEvent {
constructor(type, init = {}) {
this.type = type;
this.detail = init.detail;
}
}
const window = {
__MOVIX_BRIDGE_READY: false,
addEventListener(type, handler) {
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type).add(handler);
},
dispatchEvent(event) {
for (const handler of listeners.get(event.type) || []) handler(event);
},
fetch: async url => {
nativeFetches.push(String(url));
return {
status: 206,
statusText: 'Partial Content',
url: String(url),
headers: new Map([
['content-type', 'application/vnd.apple.mpegurl'],
['content-range', 'bytes 0-2/3'],
]),
arrayBuffer: async () => Uint8Array.from([1, 2, 3]).buffer,
text: async () => 'LOCAL',
};
},
};
window.ReactNativeWebView = {
postMessage(raw) {
const message = JSON.parse(raw);
posted.push(message);
queueMicrotask(() => {
if (message.type === 'GM_OPEN_MEDIA_PROXY') {
window.dispatchEvent(new CustomEvent('__MOVIX_BRIDGE_RESPONSE', {
detail: rejectOpen
? { id: message.id, success: false, error: 'unavailable' }
: {
id: message.id,
success: true,
value: 'http://127.0.0.1:28123/p/opaque-session',
},
}));
return;
}
window.dispatchEvent(new CustomEvent('__MOVIX_BRIDGE_RESPONSE', {
detail: {
id: message.id,
success: true,
status: 200,
statusText: 'OK',
headers: { 'content-type': 'application/octet-stream' },
body: 'BAUG',
finalUrl: message.url,
},
}));
});
},
};
const context = vm.createContext({
window,
CustomEvent,
Uint8Array,
ArrayBuffer,
URLSearchParams,
Promise,
console,
atob,
btoa,
setTimeout: () => 1,
clearTimeout: () => {},
});
vm.runInContext(buildBridgeRuntime(), context);
return { window, posted, nativeFetches };
}
function gmRequest(window, details) {
return new Promise((resolve, reject) => {
window.GM_xmlhttpRequest({
responseType: 'arraybuffer',
...details,
onload: resolve,
onerror: reject,
});
});
}
test('protected media uses the native loopback proxy without a Base64 GM_FETCH', async () => {
const { buildBridgeRuntime } = await loadBridgeRuntimeBuilder();
const harness = createRuntimeHarness(buildBridgeRuntime);
const response = await gmRequest(harness.window, {
method: 'GET',
url: 'https://u14.vidzy.cc/movie/master.m3u8?token=abc',
headers: {
Origin: 'https://vidzy.org',
Referer: 'https://vidzy.org/',
},
});
assert.deepEqual(harness.posted.map(entry => entry.type), [
'GM_OPEN_MEDIA_PROXY',
]);
assert.deepEqual(harness.nativeFetches, [
'http://127.0.0.1:28123/p/opaque-session',
]);
assert.deepEqual([...new Uint8Array(response.response)], [1, 2, 3]);
});
test('falls back to GM_FETCH when the native proxy is unavailable', async () => {
const { buildBridgeRuntime } = await loadBridgeRuntimeBuilder();
const harness = createRuntimeHarness(buildBridgeRuntime, { rejectOpen: true });
const response = await gmRequest(harness.window, {
method: 'GET',
url: 'https://r1.fsvid.lol/movie/master.m3u8',
headers: {
Origin: 'https://fsvid.lol',
Referer: 'https://fsvid.lol/',
},
});
assert.deepEqual(harness.posted.map(entry => entry.type), [
'GM_OPEN_MEDIA_PROXY',
'GM_FETCH',
]);
assert.deepEqual([...new Uint8Array(response.response)], [4, 5, 6]);
});

View file

@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const testsDirectory = path.dirname(fileURLToPath(import.meta.url));
const appDirectory = path.resolve(testsDirectory, "..");
const release = JSON.parse(
fs.readFileSync(path.join(appDirectory, "version.json"), "utf8"),
);
const appConfig = JSON.parse(
fs.readFileSync(path.join(appDirectory, "app.json"), "utf8"),
);
const gradle = fs.readFileSync(
path.join(appDirectory, "android", "app", "build.gradle"),
"utf8",
);
const normalized = (value) =>
value
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase();
test("release metadata targets the mandatory 2.5.4 build 13 update", () => {
assert.equal(release.version, "2.5.4");
assert.equal(release.buildNumber, 13);
assert.equal(release.mandatory, true);
assert.equal(
release.apkUrl,
"https://raw.githubusercontent.com/movixcorp/MovixOpenSource/main/app/movix-android.apk",
);
assert.equal(appConfig.version, "2.5.4");
assert.equal(appConfig.buildNumber, "13");
assert.match(gradle, /\bversionCode\s+13\b/);
assert.match(gradle, /\bversionName\s+"2\.5\.4"/);
});
test("French release notes aggregate the previous and current fixes", () => {
const notes = normalized(release.releaseNotes.fr);
for (const expected of [
"nexus",
"bravo",
"uqload",
"mise a jour automatique",
"ecran",
"vidzy",
"fsvid",
"proxy",
]) {
assert.ok(notes.includes(expected), `releaseNotes.fr doit contenir "${expected}"`);
}
});
test("APK integrity metadata remains publishable", () => {
assert.ok(Number.isSafeInteger(release.apkSizeBytes));
assert.ok(release.apkSizeBytes > 0);
assert.match(release.apkSha256, /^[a-f0-9]{64}$/);
assert.match(release.releasedAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});

View file

@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
canReusePendingApk,
decideUpdateForegroundAction,
} from '../src/hooks/updateResume.ts';
test('continues automatically after the install permission is granted', () => {
assert.equal(
decideUpdateForegroundAction({
stage: 'need_permission',
installPermissionGranted: true,
localBuildNumber: 10,
targetBuildNumber: 11,
}),
'continue_after_permission',
);
assert.equal(
decideUpdateForegroundAction({
stage: 'need_permission',
installPermissionGranted: false,
localBuildNumber: 10,
targetBuildNumber: 11,
}),
'none',
);
});
test('distinguishes a completed install from an installer cancellation', () => {
assert.equal(
decideUpdateForegroundAction({
stage: 'installing',
installPermissionGranted: true,
localBuildNumber: 11,
targetBuildNumber: 11,
}),
'installed',
);
assert.equal(
decideUpdateForegroundAction({
stage: 'installing',
installPermissionGranted: true,
localBuildNumber: 10,
targetBuildNumber: 11,
}),
'install_not_completed',
);
});
test('only reuses a matching, verified APK download', () => {
const validPending = {
downloadId: 42,
targetBuildNumber: 11,
targetVersion: '2.5.2',
targetSha256: 'A'.repeat(64),
apkFilePath: 'C:\\downloads\\movix-android-11.apk',
startedAt: '2026-07-27T12:00:00.000Z',
};
assert.equal(canReusePendingApk(validPending, 11), true);
assert.equal(canReusePendingApk(validPending, 12), false);
assert.equal(
canReusePendingApk({ ...validPending, apkFilePath: '' }, 11),
false,
);
assert.equal(
canReusePendingApk({ ...validPending, apkFilePath: 'movix.zip' }, 11),
false,
);
assert.equal(
canReusePendingApk({ ...validPending, targetSha256: 'bad' }, 11),
false,
);
});

View file

@ -1,13 +1,13 @@
{
"version": "2.5.3",
"buildNumber": 12,
"version": "2.5.4",
"buildNumber": 13,
"apkUrl": "https://raw.githubusercontent.com/movixcorp/MovixOpenSource/main/app/movix-android.apk",
"apkSizeBytes": 72023883,
"apkSha256": "6e63593a1c47cee9cf60414219e4e05ef11c7a72e77256ebaffb65af1cc8e977",
"mandatory": false,
"releasedAt": "2026-05-10T09:47:30.589Z",
"apkSizeBytes": 72100426,
"apkSha256": "c87f2580fd55e136859f3171bdc6b4cd7bb1f5fffeecf8b60eef791665c8d3ff",
"mandatory": true,
"releasedAt": "2026-07-27T12:41:10.712Z",
"releaseNotes": {
"fr": "Correction extraction du lecteur Uqload\nL'écran se s'etteint plus lors de l'utilisation de l'app",
"en": ""
"fr": "Correction des lecteurs Nexus et Bravo.\nCorrection de l'extraction Uqload.\nCorrection de la mise à jour automatique.\nL'écran ne s'éteint plus pendant l'utilisation de l'application.\nCorrection des proxys locaux Vidzy, Fsvid, Bravo et des autres sources protégées.",
"en": "Fixed Nexus and Bravo players.\nFixed Uqload extraction.\nFixed automatic update installation.\nThe screen now stays awake while the app is in use.\nFixed local proxies for Vidzy, Fsvid, Bravo, and other protected media sources."
}
}

View file

@ -56,6 +56,63 @@ const caches = {
// Dean Edwards packer signature — split to avoid Chrome Web Store code scanner false positives
const PACKER_MARKER = 'ev' + 'al(func' + 'tion(p,a,c,k,e,';
const PACKER_SIGNATURE_PATTERN = new RegExp(
'ev' + 'al\\s*\\(\\s*function\\s*\\(\\s*p\\s*,\\s*a\\s*,\\s*c\\s*,\\s*k\\s*,\\s*e\\s*,\\s*d\\s*\\)'
);
const UQLOAD_ROOT_DOMAINS = Object.freeze([
'uqload.is',
'uqload.bz',
'uqload.cx',
'uqload.com',
'uqload.net',
'uqload.org',
'uqload.to',
'uqload.io',
'uqload.co',
]);
function getUqloadRootDomain(hostname) {
const host = String(hostname || '').toLowerCase().replace(/\.$/, '');
return UQLOAD_ROOT_DOMAINS.find(
root => host === root || host.endsWith(`.${root}`)
) || null;
}
function parseAllowedUqloadUrl(rawUrl) {
let parsed;
try {
parsed = new URL(String(rawUrl || '').trim());
} catch {
throw new Error('Invalid Uqload URL');
}
if (
parsed.protocol !== 'https:' ||
parsed.username ||
parsed.password ||
(parsed.port && parsed.port !== '443') ||
!getUqloadRootDomain(parsed.hostname)
) {
throw new Error('Invalid Uqload URL');
}
return parsed;
}
function normalizeUqloadEmbedUrl(rawUrl) {
const parsed = parseAllowedUqloadUrl(rawUrl);
const lastPart = parsed.pathname.split('/').filter(Boolean).pop() || '';
const videoId = lastPart.replace(/^embed-/i, '').replace(/\.html$/i, '');
if (!/^[a-z0-9_-]+$/i.test(videoId)) {
throw new Error('Invalid Uqload URL');
}
return `${parsed.origin}/embed-${videoId}.html`;
}
function getUqloadSiteOrigin(rawUrl) {
const parsed = parseAllowedUqloadUrl(rawUrl);
return `https://${getUqloadRootDomain(parsed.hostname)}`;
}
function md5Hash(str) {
// Simple hash for cache keys (not cryptographic, just for dedup)
@ -199,10 +256,10 @@ function decodeDeanEdwardsPacker(packedScript, radix, keywordCount, keywords) {
* packed block was found
*/
function decodePackedScriptFromHtml(html) {
// Step 1: Locate the packed script marker in the HTML
const packerMarker = PACKER_MARKER;
const markerIndex = html.indexOf(packerMarker);
if (markerIndex === -1) return null;
// Step 1: Locate the packed script marker while tolerating formatter whitespace.
const markerMatch = PACKER_SIGNATURE_PATTERN.exec(html);
if (!markerMatch) return null;
const markerIndex = markerMatch.index;
// Step 2: Find the .split('|') call that marks the end of the keyword
// list — this tells us where the packed block ends
@ -227,11 +284,47 @@ function decodePackedScriptFromHtml(html) {
const radix = parseInt(match[2]);
const keywordCount = parseInt(match[3]);
const keywords = match[4].split('|');
if (
radix < 2 ||
radix > 62 ||
keywordCount < 0 ||
keywordCount > 10000 ||
keywordCount > keywords.length
) {
return null;
}
// Step 6: Decode and return the original script
return decodeDeanEdwardsPacker(packedTemplate, radix, keywordCount, keywords);
}
function extractUqloadMediaUrl(html) {
const candidates = [];
const collect = value => {
const normalized = String(value || '').replace(/\\\//g, '/');
for (const match of normalized.matchAll(/https:\/\/[^\s"'\\<>]+/gi)) {
const candidate = match[0].replace(/[),;]+$/, '');
try {
parseAllowedUqloadUrl(candidate);
candidates.push(candidate);
} catch {
// Ignore URLs outside the Uqload domain allowlist.
}
}
};
collect(html);
const decoded = decodePackedScriptFromHtml(html);
if (decoded) collect(decoded);
return (
candidates.find(url => /\/master\.m3u8(?:[?#]|$)/i.test(url)) ||
candidates.find(url => /\.m3u8(?:[?#]|$)/i.test(url)) ||
candidates.find(url => /\/v\.mp4(?:[?#]|$)/i.test(url)) ||
null
);
}
/**
* Extract JSON from VOE HTML
*/
@ -704,7 +797,7 @@ async function extractSibnet(sibnetUrl) {
}
/**
* Extract MP4 from Uqload embed
* Extract HLS or MP4 from Uqload embed
*/
async function extractUqload(uqloadUrl) {
console.log(`[EXT-UQLOAD] Extracting from: ${uqloadUrl}`);
@ -713,29 +806,21 @@ async function extractUqload(uqloadUrl) {
const cached = caches.uqload.get(cacheKey);
if (cached) return { ...cached, fromCache: true };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
// Normalize URL
let normalized = uqloadUrl.replace(/uqload\.(cx|com|net|co)/gi, 'uqload.bz');
// Validate and format
const parts = normalized.split('/');
const base = parts.slice(0, -1).join('/') || 'https://uqload.bz';
let videoId = parts[parts.length - 1];
if (!videoId.includes('.html')) videoId += '.html';
if (!videoId.includes('embed-')) videoId = 'embed-' + videoId;
const fullUrl = `${base}/${videoId}`;
const fullUrl = normalizeUqloadEmbedUrl(uqloadUrl);
const siteOrigin = getUqloadSiteOrigin(fullUrl);
const headers = {
'User-Agent': 'Mozilla/5.0 Chrome/91.0.0.0',
'Accept': 'text/html,*/*'
'Accept': 'text/html,*/*',
'Referer': `${siteOrigin}/`,
'Origin': siteOrigin,
};
// Try embed and non-embed versions
const urls = [fullUrl, fullUrl.replace('embed-', '')];
// Try embed and non-embed versions without leaving the validated host.
const urls = [fullUrl, fullUrl.replace('/embed-', '/')];
let html = null;
for (const url of urls) {
@ -745,31 +830,25 @@ async function extractUqload(uqloadUrl) {
html = await resp.text();
break;
}
} catch { continue; }
} catch {
continue;
}
}
clearTimeout(timer);
if (!html) return { success: false, error: 'Uqload: Could not fetch page' };
if (html.includes('File was deleted')) return { success: false, error: 'Uqload: File was deleted' };
// Préférer le HLS master.m3u8 (multi-bitrate) au mp4 single-quality
const m3u8Matches = html.match(/https?:\/\/[^"'\s]+\/master\.m3u8/g) || html.match(/https?:\/\/[^"'\s]+\.m3u8/g);
let videoUrl = m3u8Matches?.[0];
if (!videoUrl) {
const mp4Matches = html.match(/https?:\/\/.+\/v\.mp4/g);
videoUrl = mp4Matches?.[0];
}
const videoUrl = extractUqloadMediaUrl(html);
if (!videoUrl) return { success: false, error: 'Uqload: video URL not found' };
const result = { m3u8Url: videoUrl, success: true, source: 'uqload' };
caches.uqload.set(cacheKey, result);
return result;
} catch (e) {
console.error('[EXT-UQLOAD] Error:', e);
return { success: false, error: e.message || 'Uqload extraction failed' };
} finally {
clearTimeout(timer);
}
}
@ -942,7 +1021,7 @@ const EMBED_PATTERNS = {
vidzy: url => url.toLowerCase().includes('vidzy'),
vidmoly: url => url.toLowerCase().includes('vidmoly'),
sibnet: url => url.toLowerCase().includes('sibnet.ru'),
uqload: url => /uqload\.(cx|com|bz|net|org|to|io|co)/i.test(url),
uqload: url => /uqload\.(is|cx|com|bz|net|org|to|io|co)/i.test(url),
doodstream: url => {
const lower = url.toLowerCase();
return lower.includes('d0000d.com') || lower.includes('doodstream.com') || lower.includes('dood.')
@ -1062,6 +1141,7 @@ async function setupHeadersForService(type, url, referer) {
// - Embed page (fsvid.lol/embed-xxx) → fs12.lol (required by fsvid to serve content)
// - CDN/M3U8 (s1.fsvid.lol, s2.fsvid.lol, etc.) → fsvid.lol (required by CDN)
let fsvidHeaders;
let uqloadHeaders;
if (type === 'fsvid' && url) {
try {
const hostname = new URL(url).hostname;
@ -1076,6 +1156,14 @@ async function setupHeadersForService(type, url, referer) {
fsvidHeaders = { 'Referer': 'https://fsvid.lol/', 'Origin': 'https://fsvid.lol' };
}
}
if (type === 'uqload' && url) {
try {
const origin = getUqloadSiteOrigin(url);
uqloadHeaders = { 'Referer': `${origin}/`, 'Origin': origin };
} catch {
return null;
}
}
const headerMap = {
voe: { 'Referer': 'https://voe.sx/', 'Origin': 'https://voe.sx' },
@ -1083,7 +1171,7 @@ async function setupHeadersForService(type, url, referer) {
vidzy: { 'Referer': 'https://vidzy.org/', 'Origin': 'https://vidzy.org' },
vidmoly: { 'Referer': 'https://voirdrama.to/', 'Origin': 'https://voirdrama.to' },
sibnet: { 'Referer': 'https://video.sibnet.ru/', 'Origin': 'https://video.sibnet.ru' },
uqload: { 'Referer': 'https://uqload.bz/', 'Origin': 'https://uqload.bz' },
uqload: uqloadHeaders,
doodstream: { 'Referer': referer || 'https://d0000d.com/', 'Origin': referer ? new URL(referer).origin : 'https://d0000d.com' },
seekstreaming: { 'Referer': referer || 'https://lpayer.embed4me.com/', 'Origin': referer ? new URL(referer).origin : 'https://lpayer.embed4me.com' },
cinep: { 'Referer': 'https://purstream.mx/', 'Origin': 'https://purstream.mx' },

View file

@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Movix Proxy Extension",
"version": "1.3.9",
"version": "1.3.10",
"description": "Extension proxy pour Live TV Movix - Contourne CORS, injecte les headers et extrait les sources Nexus",
"icons": {
"16": "movix.png",

View file

@ -54,6 +54,64 @@ const caches = {
// ===== Utility Functions =====
const PACKER_SIGNATURE_PATTERN = new RegExp(
'ev' + 'al\\s*\\(\\s*function\\s*\\(\\s*p\\s*,\\s*a\\s*,\\s*c\\s*,\\s*k\\s*,\\s*e\\s*,\\s*d\\s*\\)'
);
const UQLOAD_ROOT_DOMAINS = Object.freeze([
'uqload.is',
'uqload.bz',
'uqload.cx',
'uqload.com',
'uqload.net',
'uqload.org',
'uqload.to',
'uqload.io',
'uqload.co',
]);
function getUqloadRootDomain(hostname) {
const host = String(hostname || '').toLowerCase().replace(/\.$/, '');
return UQLOAD_ROOT_DOMAINS.find(
root => host === root || host.endsWith(`.${root}`)
) || null;
}
function parseAllowedUqloadUrl(rawUrl) {
let parsed;
try {
parsed = new URL(String(rawUrl || '').trim());
} catch {
throw new Error('Invalid Uqload URL');
}
if (
parsed.protocol !== 'https:' ||
parsed.username ||
parsed.password ||
(parsed.port && parsed.port !== '443') ||
!getUqloadRootDomain(parsed.hostname)
) {
throw new Error('Invalid Uqload URL');
}
return parsed;
}
function normalizeUqloadEmbedUrl(rawUrl) {
const parsed = parseAllowedUqloadUrl(rawUrl);
const lastPart = parsed.pathname.split('/').filter(Boolean).pop() || '';
const videoId = lastPart.replace(/^embed-/i, '').replace(/\.html$/i, '');
if (!/^[a-z0-9_-]+$/i.test(videoId)) {
throw new Error('Invalid Uqload URL');
}
return `${parsed.origin}/embed-${videoId}.html`;
}
function getUqloadSiteOrigin(rawUrl) {
const parsed = parseAllowedUqloadUrl(rawUrl);
return `https://${getUqloadRootDomain(parsed.hostname)}`;
}
function md5Hash(str) {
// Simple hash for cache keys (not cryptographic, just for dedup)
let hash = 0;
@ -154,9 +212,10 @@ function unpackPacker(p, a, c, k, e, d) {
* Properly handles escaped quotes inside the payload string
*/
function deobfuscatePackedScript(html) {
// Find the eval(function(p,a,c,k pattern
const evalIdx = html.indexOf('eval(function(p,a,c,k,e,');
if (evalIdx === -1) return null;
// Find the packer signature while tolerating formatter whitespace.
const markerMatch = PACKER_SIGNATURE_PATTERN.exec(html);
if (!markerMatch) return null;
const evalIdx = markerMatch.index;
// Find .split('|') after eval to locate end of packer call
let splitPos = html.indexOf(".split('|')", evalIdx);
@ -176,6 +235,9 @@ function deobfuscatePackedScript(html) {
const radix = parseInt(match[2]);
const count = parseInt(match[3]);
const keywords = match[4].split('|');
if (radix < 2 || radix > 62 || count < 0 || count > 10000 || count > keywords.length) {
return null;
}
return unpackPacker(payload, radix, count, keywords, null, {});
}
@ -188,12 +250,42 @@ function deobfuscatePackedScript(html) {
const radix = parseInt(match2[2]);
const count = parseInt(match2[3]);
const keywords = match2[4].split('|');
if (radix < 2 || radix > 62 || count < 0 || count > 10000 || count > keywords.length) {
return null;
}
return unpackPacker(payload, radix, count, keywords, null, {});
}
return null;
}
function extractUqloadMediaUrl(html) {
const candidates = [];
const collect = value => {
const normalized = String(value || '').replace(/\\\//g, '/');
for (const match of normalized.matchAll(/https:\/\/[^\s"'\\<>]+/gi)) {
const candidate = match[0].replace(/[),;]+$/, '');
try {
parseAllowedUqloadUrl(candidate);
candidates.push(candidate);
} catch {
// Ignore URLs outside the Uqload domain allowlist.
}
}
};
collect(html);
const decoded = deobfuscatePackedScript(html);
if (decoded) collect(decoded);
return (
candidates.find(url => /\/master\.m3u8(?:[?#]|$)/i.test(url)) ||
candidates.find(url => /\.m3u8(?:[?#]|$)/i.test(url)) ||
candidates.find(url => /\/v\.mp4(?:[?#]|$)/i.test(url)) ||
null
);
}
/**
* Extract JSON from VOE HTML
*/
@ -666,7 +758,7 @@ async function extractSibnet(sibnetUrl) {
}
/**
* Extract MP4 from Uqload embed
* Extract HLS or MP4 from Uqload embed
*/
async function extractUqload(uqloadUrl) {
console.log(`[EXT-UQLOAD] Extracting from: ${uqloadUrl}`);
@ -675,29 +767,21 @@ async function extractUqload(uqloadUrl) {
const cached = caches.uqload.get(cacheKey);
if (cached) return { ...cached, fromCache: true };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
// Normalize URL
let normalized = uqloadUrl.replace(/uqload\.(cx|com|net|co)/gi, 'uqload.bz');
// Validate and format
const parts = normalized.split('/');
const base = parts.slice(0, -1).join('/') || 'https://uqload.bz';
let videoId = parts[parts.length - 1];
if (!videoId.includes('.html')) videoId += '.html';
if (!videoId.includes('embed-')) videoId = 'embed-' + videoId;
const fullUrl = `${base}/${videoId}`;
const fullUrl = normalizeUqloadEmbedUrl(uqloadUrl);
const siteOrigin = getUqloadSiteOrigin(fullUrl);
const headers = {
'User-Agent': 'Mozilla/5.0 Chrome/91.0.0.0',
'Accept': 'text/html,*/*'
'Accept': 'text/html,*/*',
'Referer': `${siteOrigin}/`,
'Origin': siteOrigin,
};
// Try embed and non-embed versions
const urls = [fullUrl, fullUrl.replace('embed-', '')];
// Try embed and non-embed versions without leaving the validated host.
const urls = [fullUrl, fullUrl.replace('/embed-', '/')];
let html = null;
for (const url of urls) {
@ -707,31 +791,25 @@ async function extractUqload(uqloadUrl) {
html = await resp.text();
break;
}
} catch { continue; }
} catch {
continue;
}
}
clearTimeout(timer);
if (!html) return { success: false, error: 'Uqload: Could not fetch page' };
if (html.includes('File was deleted')) return { success: false, error: 'Uqload: File was deleted' };
// Préférer le HLS master.m3u8 (multi-bitrate) au mp4 single-quality
const m3u8Matches = html.match(/https?:\/\/[^"'\s]+\/master\.m3u8/g) || html.match(/https?:\/\/[^"'\s]+\.m3u8/g);
let videoUrl = m3u8Matches?.[0];
if (!videoUrl) {
const mp4Matches = html.match(/https?:\/\/.+\/v\.mp4/g);
videoUrl = mp4Matches?.[0];
}
const videoUrl = extractUqloadMediaUrl(html);
if (!videoUrl) return { success: false, error: 'Uqload: video URL not found' };
const result = { m3u8Url: videoUrl, success: true, source: 'uqload' };
caches.uqload.set(cacheKey, result);
return result;
} catch (e) {
console.error('[EXT-UQLOAD] Error:', e);
return { success: false, error: e.message || 'Uqload extraction failed' };
} finally {
clearTimeout(timer);
}
}
@ -904,7 +982,7 @@ const EMBED_PATTERNS = {
vidzy: url => url.toLowerCase().includes('vidzy'),
vidmoly: url => url.toLowerCase().includes('vidmoly'),
sibnet: url => url.toLowerCase().includes('sibnet.ru'),
uqload: url => /uqload\.(cx|com|bz|net|org|to|io|co)/i.test(url),
uqload: url => /uqload\.(is|cx|com|bz|net|org|to|io|co)/i.test(url),
doodstream: url => {
const lower = url.toLowerCase();
return lower.includes('d0000d.com') || lower.includes('doodstream.com') || lower.includes('dood.')
@ -1024,6 +1102,7 @@ async function setupHeadersForService(type, url, referer) {
// - Embed page (fsvid.lol/embed-xxx) → fs12.lol (required by fsvid to serve content)
// - CDN/M3U8 (s1.fsvid.lol, s2.fsvid.lol, etc.) → fsvid.lol (required by CDN)
let fsvidHeaders;
let uqloadHeaders;
if (type === 'fsvid' && url) {
try {
const hostname = new URL(url).hostname;
@ -1038,6 +1117,14 @@ async function setupHeadersForService(type, url, referer) {
fsvidHeaders = { 'Referer': 'https://fsvid.lol/', 'Origin': 'https://fsvid.lol' };
}
}
if (type === 'uqload' && url) {
try {
const origin = getUqloadSiteOrigin(url);
uqloadHeaders = { 'Referer': `${origin}/`, 'Origin': origin };
} catch {
return null;
}
}
const headerMap = {
voe: { 'Referer': 'https://voe.sx/', 'Origin': 'https://voe.sx' },
@ -1045,7 +1132,7 @@ async function setupHeadersForService(type, url, referer) {
vidzy: { 'Referer': 'https://vidzy.org/', 'Origin': 'https://vidzy.org' },
vidmoly: { 'Referer': 'https://voirdrama.to/', 'Origin': 'https://voirdrama.to' },
sibnet: { 'Referer': 'https://video.sibnet.ru/', 'Origin': 'https://video.sibnet.ru' },
uqload: { 'Referer': 'https://uqload.bz/', 'Origin': 'https://uqload.bz' },
uqload: uqloadHeaders,
doodstream: { 'Referer': referer || 'https://d0000d.com/', 'Origin': referer ? new URL(referer).origin : 'https://d0000d.com' },
seekstreaming: { 'Referer': referer || 'https://lpayer.embed4me.com/', 'Origin': referer ? new URL(referer).origin : 'https://lpayer.embed4me.com' },
cinep: { 'Referer': 'https://purstream.mx/', 'Origin': 'https://purstream.mx' },

View file

@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Movix Proxy Extension",
"version": "1.5.6",
"version": "1.5.7",
"description": "Extension proxy pour Live TV Movix - Contourne CORS, injecte les headers et extrait les sources Nexus",
"browser_specific_settings": {
"gecko": {

View file

@ -1,7 +1,7 @@
// ==UserScript==
// @name Movix Proxy Extension (Tampermonkey)
// @namespace https://movix.cash
// @version 1.4.8
// @version 1.4.9
// @description Extension proxy pour Live TV Movix - Contourne CORS, injecte les headers et extrait les sources Nexus - version userscript Tampermonkey
// @author Movix
// @updateURL https://github.com/movixcorp/MovixOpenSource/raw/refs/heads/main/userscript/movix.user.js
@ -43,7 +43,7 @@
const USERSCRIPT_MANIFEST = {
name: "Movix Proxy Extension",
version: "1.4.8",
version: "1.4.9",
description:
"Extension proxy pour Live TV Movix - Contourne CORS, injecte les headers et extrait les sources Nexus",
};
@ -1093,6 +1093,70 @@
// Dean Edwards packer signature — split to avoid Chrome Web Store code scanner false positives
const PACKER_MARKER = "ev" + "al(func" + "tion(p,a,c,k,e,";
const PACKER_SIGNATURE_PATTERN = new RegExp(
"ev" +
"al\\s*\\(\\s*function\\s*\\(\\s*p\\s*,\\s*a\\s*,\\s*c\\s*,\\s*k\\s*,\\s*e\\s*,\\s*d\\s*\\)",
);
const UQLOAD_ROOT_DOMAINS = Object.freeze([
"uqload.is",
"uqload.bz",
"uqload.cx",
"uqload.com",
"uqload.net",
"uqload.org",
"uqload.to",
"uqload.io",
"uqload.co",
]);
function getUqloadRootDomain(hostname) {
const host = String(hostname || "")
.toLowerCase()
.replace(/\.$/, "");
return (
UQLOAD_ROOT_DOMAINS.find(
(root) => host === root || host.endsWith(`.${root}`),
) || null
);
}
function parseAllowedUqloadUrl(rawUrl) {
let parsed;
try {
parsed = new URL(String(rawUrl || "").trim());
} catch {
throw new Error("Invalid Uqload URL");
}
if (
parsed.protocol !== "https:" ||
parsed.username ||
parsed.password ||
(parsed.port && parsed.port !== "443") ||
!getUqloadRootDomain(parsed.hostname)
) {
throw new Error("Invalid Uqload URL");
}
return parsed;
}
function normalizeUqloadEmbedUrl(rawUrl) {
const parsed = parseAllowedUqloadUrl(rawUrl);
const lastPart = parsed.pathname.split("/").filter(Boolean).pop() || "";
const videoId = lastPart
.replace(/^embed-/i, "")
.replace(/\.html$/i, "");
if (!/^[a-z0-9_-]+$/i.test(videoId)) {
throw new Error("Invalid Uqload URL");
}
return `${parsed.origin}/embed-${videoId}.html`;
}
function getUqloadSiteOrigin(rawUrl) {
const parsed = parseAllowedUqloadUrl(rawUrl);
return `https://${getUqloadRootDomain(parsed.hostname)}`;
}
function md5Hash(str) {
// Simple hash for cache keys (not cryptographic, just for dedup)
@ -1256,10 +1320,10 @@
* packed block was found
*/
function decodePackedScriptFromHtml(html) {
// Step 1: Locate the packed script marker in the HTML
const packerMarker = PACKER_MARKER;
const markerIndex = html.indexOf(packerMarker);
if (markerIndex === -1) return null;
// Step 1: Locate the packed script marker while tolerating formatter whitespace.
const markerMatch = PACKER_SIGNATURE_PATTERN.exec(html);
if (!markerMatch) return null;
const markerIndex = markerMatch.index;
// Step 2: Find the .split('|') call that marks the end of the keyword
// list — this tells us where the packed block ends
@ -1289,6 +1353,15 @@
const radix = parseInt(match[2]);
const keywordCount = parseInt(match[3]);
const keywords = match[4].split("|");
if (
radix < 2 ||
radix > 62 ||
keywordCount < 0 ||
keywordCount > 10000 ||
keywordCount > keywords.length
) {
return null;
}
// Step 6: Decode and return the original script
return decodeDeanEdwardsPacker(
@ -1299,6 +1372,33 @@
);
}
function extractUqloadMediaUrl(html) {
const candidates = [];
const collect = (value) => {
const normalized = String(value || "").replace(/\\\//g, "/");
for (const match of normalized.matchAll(/https:\/\/[^\s"'\\<>]+/gi)) {
const candidate = match[0].replace(/[),;]+$/, "");
try {
parseAllowedUqloadUrl(candidate);
candidates.push(candidate);
} catch {
// Ignore URLs outside the Uqload domain allowlist.
}
}
};
collect(html);
const decoded = decodePackedScriptFromHtml(html);
if (decoded) collect(decoded);
return (
candidates.find((url) => /\/master\.m3u8(?:[?#]|$)/i.test(url)) ||
candidates.find((url) => /\.m3u8(?:[?#]|$)/i.test(url)) ||
candidates.find((url) => /\/v\.mp4(?:[?#]|$)/i.test(url)) ||
null
);
}
/**
* Extract JSON from VOE HTML
*/
@ -1847,7 +1947,7 @@
}
/**
* Extract MP4 from Uqload embed
* Extract HLS or MP4 from Uqload embed
*/
async function extractUqload(uqloadUrl) {
console.log(`[EXT-UQLOAD] Extracting from: ${uqloadUrl}`);
@ -1856,32 +1956,21 @@
const cached = caches.uqload.get(cacheKey);
if (cached) return { ...cached, fromCache: true };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
// Normalize URL
let normalized = uqloadUrl.replace(
/uqload\.(cx|com|net|co)/gi,
"uqload.bz",
);
// Validate and format
const parts = normalized.split("/");
const base = parts.slice(0, -1).join("/") || "https://uqload.bz";
let videoId = parts[parts.length - 1];
if (!videoId.includes(".html")) videoId += ".html";
if (!videoId.includes("embed-")) videoId = "embed-" + videoId;
const fullUrl = `${base}/${videoId}`;
const fullUrl = normalizeUqloadEmbedUrl(uqloadUrl);
const siteOrigin = getUqloadSiteOrigin(fullUrl);
const headers = {
"User-Agent": "Mozilla/5.0 Chrome/91.0.0.0",
Accept: "text/html,*/*",
Referer: `${siteOrigin}/`,
Origin: siteOrigin,
};
// Try embed and non-embed versions
const urls = [fullUrl, fullUrl.replace("embed-", "")];
// Try embed and non-embed versions without leaving the validated host.
const urls = [fullUrl, fullUrl.replace("/embed-", "/")];
let html = null;
for (const url of urls) {
@ -1896,23 +1985,12 @@
}
}
clearTimeout(timer);
if (!html)
return { success: false, error: "Uqload: Could not fetch page" };
if (html.includes("File was deleted"))
return { success: false, error: "Uqload: File was deleted" };
// Préférer le HLS master.m3u8 (multi-bitrate) au mp4 single-quality
const m3u8Matches =
html.match(/https?:\/\/[^"'\s]+\/master\.m3u8/g) ||
html.match(/https?:\/\/[^"'\s]+\.m3u8/g);
let videoUrl = m3u8Matches?.[0];
if (!videoUrl) {
const mp4Matches = html.match(/https?:\/\/.+\/v\.mp4/g);
videoUrl = mp4Matches?.[0];
}
const videoUrl = extractUqloadMediaUrl(html);
if (!videoUrl)
return { success: false, error: "Uqload: video URL not found" };
@ -1922,6 +2000,8 @@
} catch (e) {
console.error("[EXT-UQLOAD] Error:", e);
return { success: false, error: e.message || "Uqload extraction failed" };
} finally {
clearTimeout(timer);
}
}
@ -2136,7 +2216,7 @@
vidzy: (url) => url.toLowerCase().includes("vidzy"),
vidmoly: (url) => url.toLowerCase().includes("vidmoly"),
sibnet: (url) => url.toLowerCase().includes("sibnet.ru"),
uqload: (url) => /uqload\.(cx|com|bz|net|org|to|io|co)/i.test(url),
uqload: (url) => /uqload\.(is|cx|com|bz|net|org|to|io|co)/i.test(url),
doodstream: (url) => {
const lower = url.toLowerCase();
return (
@ -2282,6 +2362,7 @@
// - Embed page (fsvid.lol/embed-xxx) → fs13.lol (required by fsvid to serve content)
// - CDN/M3U8 (s1.fsvid.lol, s2.fsvid.lol, etc.) → fsvid.lol (required by CDN)
let fsvidHeaders;
let uqloadHeaders;
if (type === "fsvid" && url) {
try {
const hostname = new URL(url).hostname;
@ -2305,6 +2386,14 @@
};
}
}
if (type === "uqload" && url) {
try {
const origin = getUqloadSiteOrigin(url);
uqloadHeaders = { Referer: `${origin}/`, Origin: origin };
} catch {
return null;
}
}
const headerMap = {
voe: { Referer: "https://voe.sx/", Origin: "https://voe.sx" },
@ -2321,7 +2410,7 @@
Referer: "https://video.sibnet.ru/",
Origin: "https://video.sibnet.ru",
},
uqload: { Referer: "https://uqload.bz/", Origin: "https://uqload.bz" },
uqload: uqloadHeaders,
doodstream: {
Referer: referer || "https://d0000d.com/",
Origin: referer ? new URL(referer).origin : "https://d0000d.com",