Compare commits

...

10 Commits

Author SHA1 Message Date
Koen J a792dea4c5 Build fix. 2026-05-08 17:50:30 +02:00
Koen J a7fc549afb Automatic updates for plugins defaults to true and made the loading bar smaller. 2026-05-08 17:40:57 +02:00
Koen J b345ba5ca3 Updated submodules. 2026-05-08 16:12:50 +02:00
Koen J c65cee86b1 Implemented feedback for update flow. 2026-05-07 22:53:26 +02:00
Koen cf3fc61f6a Merge branch 'request-modifiers' into 'master'
Improve request modifier support for casting and downloads

See merge request videostreaming/grayjay!171
2026-04-30 15:32:54 +00:00
Koen J d03019f0b7 Fixes for fdroid and playstore automation. 2026-04-30 12:24:50 +02:00
Koen J f1ce0078fd Switch to rel.grayjay.app 2026-04-30 11:03:44 +02:00
Koen J 32de3649ef Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2026-04-30 10:10:17 +02:00
Koen J 1a301236da Changed background download to default. 2026-04-30 10:04:44 +02:00
Stefan cd90497a59 Improve request modifier support for casting and downloads 2026-04-08 13:32:39 +01:00
56 changed files with 585 additions and 294 deletions
+8 -1
View File
@@ -1,3 +1,6 @@
variables:
GIT_SUBMODULE_STRATEGY: recursive
buildAndDeployApkUnstable:
stage: build
script:
@@ -31,7 +34,7 @@ buildAndDeployPlaystore:
stage: deploy
script:
- sh build-playstore.sh
- bash tools/venv_playstore.sh
- bash venv-playstore.sh
- . .venv-playstore/bin/activate
- python publish_playstore.py --sa /root/grayjay.json --package com.futo.platformplayer.playstore --aab ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab --track production --status completed
only:
@@ -53,5 +56,9 @@ updateFdroidRepo:
needs:
- job: buildAndDeployApkStable
artifacts: true
before_script:
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- touch ~/.ssh/known_hosts && chmod 644 ~/.ssh/known_hosts
- ssh-keygen -F gitlab.futo.org >/dev/null 2>&1 || ssh-keyscan -t rsa,ecdsa,ed25519 gitlab.futo.org >> ~/.ssh/known_hosts
script:
- python3 update_fdroid_index.py
@@ -882,7 +882,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
//@DropdownFieldOptionsId(R.array.background_download)
var shouldBackgroundDownload: Boolean = false;
var shouldBackgroundDownload: Boolean = true;
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
@DropdownFieldOptionsId(R.array.when_download)
@@ -32,6 +32,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.downloads.VideoLocal
@@ -382,7 +383,8 @@ class UISlideOverlays {
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl)
val modifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl, HashMap(), modifier)
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
val masterPlaylistContent = masterPlaylistResponse.body?.string()
@@ -515,7 +517,7 @@ class UISlideOverlays {
slideUpMenuOverlay.onOK.subscribe {
//TODO: Fix SubtitleRawSource issue
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null);
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null, videoModifier = modifier, audioModifier = modifier);
slideUpMenuOverlay.hide()
}
@@ -526,11 +528,11 @@ class UISlideOverlays {
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
withContext(Dispatchers.Main) {
if (source is IHLSManifestSource) {
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null)
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null, videoModifier = modifier)
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
slideUpMenuOverlay.hide()
} else if (source is IHLSManifestAudioSource) {
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null)
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null, audioModifier = modifier)
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
slideUpMenuOverlay.hide()
} else {
@@ -5,50 +5,14 @@ import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
import java.io.File
class UpdateActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
UpdateNotificationManager.ACTION_UPDATE_YES -> handleUpdateYes(context, intent)
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
}
}
private fun handleUpdateYes(context: Context, intent: Intent) {
AutoUpdateDialog.currentDialog?.dismiss()
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
if (version == 0) {
return
}
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
}
ContextCompat.startForegroundService(context, serviceIntent)
}
private fun handleUpdateNo(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
}
private fun handleUpdateNever(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
Settings.instance.autoUpdate.check = 1
Settings.instance.save()
UpdateNotificationManager.cancelAll(context)
}
private fun handleDownloadCancel(context: Context, intent: Intent) {
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
@@ -1,11 +1,12 @@
package com.futo.platformplayer
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -35,18 +36,16 @@ class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : C
return@withContext Result.success()
}
UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, latestVersion)
StateUpdate.Companion.instance.setUiAvailable(latestVersion)
if (StateApp.instance.isMainActive) {
withContext(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
UIDialogs.showUpdateAvailableDialog(ctx, latestVersion, false)
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update dialog from worker", t)
}
}
try {
val serviceIntent = Intent(applicationContext, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, latestVersion)
}
ContextCompat.startForegroundService(applicationContext, serviceIntent)
} catch (t: Throwable) {
Logger.w(TAG, "Failed to start UpdateDownloadService", t)
StateUpdate.Companion.instance.setUiFailed(latestVersion, t.message)
}
Result.success()
@@ -1,17 +1,15 @@
package com.futo.platformplayer
import android.app.Dialog
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.SystemClock
import com.futo.platformplayer.UIDialogs.ActionStyle
import androidx.core.app.NotificationManagerCompat
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.SessionAnnouncement
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.*
import java.io.File
@@ -30,8 +28,6 @@ class UpdateDownloadService : Service() {
private const val INITIAL_BACKOFF_MS = 5_000L
private const val BUFFER_SIZE = 8 * 1024
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
var updateDownloadedDialog: Dialog? = null
}
private val job = SupervisorJob()
@@ -56,6 +52,7 @@ class UpdateDownloadService : Service() {
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
cancelRequested = true
Logger.i(TAG, "Download cancel requested")
StateUpdate.Companion.instance.clearUi()
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
@@ -75,6 +72,10 @@ class UpdateDownloadService : Service() {
isDownloading = true
cancelRequested = false
StateUpdate.Companion.instance.setUiDownloading(version, 0, indeterminate = true)
NotificationManagerCompat.from(this).cancel(UpdateNotificationManager.NOTIF_ID_READY)
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
@@ -97,6 +98,7 @@ class UpdateDownloadService : Service() {
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
lastProgressUpdateElapsedMs = now
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
StateUpdate.Companion.instance.setUiDownloading(version, progress, indeterminate)
if(onProgress != null)
onProgress.invoke(progress);
@@ -132,7 +134,7 @@ class UpdateDownloadService : Service() {
}
try {
performDownload(StateUpdate.APK_URL, partialFile, version, {
performDownload(StateUpdate.getApkUrl(version), partialFile, version, {
try {
if (announcement != null)
announcement?.setProgress(it);
@@ -159,6 +161,7 @@ class UpdateDownloadService : Service() {
if (attempt == MAX_RETRIES - 1) {
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
StateUpdate.Companion.instance.setUiFailed(version, t.message)
break
} else {
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
@@ -264,39 +267,16 @@ class UpdateDownloadService : Service() {
private fun onDownloadComplete(version: Int, apkFile: File) {
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
StateUpdate.Companion.instance.setUiReady(version, apkFile)
if (StateApp.instance.isMainActive) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
"Update downloaded",
"Would you like to install it now?", null, 0,
UIDialogs.Action("Not now", {
updateDownloadedDialog = null
}, ActionStyle.NONE, true),
UIDialogs.Action("Install", {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
}, ActionStyle.PRIMARY, true));
try {
StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.",
AnnouncementType.SESSION,
OffsetDateTime.now(), "update", "Install", {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
});
}
catch(ex: Throwable) {
}
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
updateDownloadedDialog = null
}
}
try {
val ctx = applicationContext
StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.", AnnouncementType.SESSION, OffsetDateTime.now(), "update", "Install") {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
}
} catch (ex: Throwable) {
Logger.w(TAG, "Failed to register install announcement", ex)
}
}
}
@@ -21,6 +21,7 @@ import java.io.InputStream
import androidx.core.net.toUri
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
object UpdateInstaller {
private const val TAG = "UpdateInstaller"
@@ -61,6 +62,17 @@ object UpdateInstaller {
var inputStream: InputStream? = null
var session: PackageInstaller.Session? = null
try {
val dataLength = apkFile.length()
val usable = try { context.filesDir.usableSpace } catch (_: Throwable) { -1L }
if (usable in 0 until dataLength) {
val msg = "Not enough storage to install update. Need ${dataLength / 1_048_576L}MB, have ${usable / 1_048_576L}MB free."
Logger.w(TAG, msg)
withContext(Dispatchers.Main) {
UIDialogs.toast(context, msg)
}
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, msg)
return@launch
}
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
@@ -68,7 +80,6 @@ object UpdateInstaller {
session = packageInstaller.openSession(sessionId)
inputStream = apkFile.inputStream()
val dataLength = apkFile.length()
session.openWrite("package", 0, dataLength).use { sessionStream ->
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
@@ -91,11 +102,18 @@ object UpdateInstaller {
} catch (e: Throwable) {
Logger.w(TAG, "Exception while installing update", e)
session?.abandon()
val raw = e.message ?: ""
val friendly = if (raw.contains("Failed to allocate") || raw.contains("allocatable") || raw.contains("ENOSPC", ignoreCase = true)) {
"Not enough storage to install update. Free up some space and try again."
} else {
"Failed to install update: $raw"
}
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to install update: ${e.message}")
UIDialogs.toast(context, friendly)
}
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, friendly)
} finally {
session?.close()
inputStream?.close()
@@ -110,10 +128,12 @@ object UpdateInstaller {
if (result.isNullOrEmpty()) {
Logger.i(TAG, "Update install finished successfully")
UpdateNotificationManager.showInstallSucceededNotification(context, version)
StateUpdate.instance.clearUi()
} else {
Logger.w(TAG, "Update install failed: $result")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
StateUpdate.instance.setUiReady(version, apkFile)
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle install result", e)
@@ -22,9 +22,6 @@ object UpdateNotificationManager {
private const val CHANNEL_NAME = "App updates"
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
const val ACTION_UPDATE_YES = "com.futo.platformplayer.UPDATE_YES"
const val ACTION_UPDATE_NO = "com.futo.platformplayer.UPDATE_NO"
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
private const val REQUEST_CODE_INSTALL = 1001
@@ -32,7 +29,6 @@ object UpdateNotificationManager {
const val EXTRA_VERSION = "version"
const val EXTRA_APK_PATH = "apk_path"
const val NOTIF_ID_AVAILABLE = 2001
const val NOTIF_ID_DOWNLOADING = 2002
const val NOTIF_ID_READY = 2003
const val NOTIF_ID_INSTALL_FAILED = 2004
@@ -84,43 +80,6 @@ object UpdateNotificationManager {
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
}
fun showUpdateAvailableNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val yesIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_YES
putExtra(EXTRA_VERSION, version)
}
val yesPendingIntent = getBroadcast(context, 0, yesIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val noIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NO
putExtra(EXTRA_VERSION, version)
}
val noPendingIntent = getBroadcast(context, 1, noIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val neverIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NEVER
putExtra(EXTRA_VERSION, version)
}
val neverPendingIntent = getBroadcast(context, 2, neverIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update available")
.setContentText("A new version ($version) is available.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setContentIntent(yesPendingIntent)
.setSilent(true)
.addAction(0, "Never", neverPendingIntent)
.addAction(0, "Not now", noPendingIntent)
.addAction(0, "Download", yesPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
}
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
ensureChannel(context)
@@ -223,11 +182,9 @@ object UpdateNotificationManager {
}
fun cancelAll(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
}
}
@@ -23,6 +23,7 @@ import java.time.Duration
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import kotlin.system.measureTimeMillis
open class ManagedHttpClient {
@@ -89,10 +90,16 @@ open class ManagedHttpClient {
return clonedClient;
}
fun tryHead(url: String): Map<String, String>? {
private fun applyModifier(url: String, headers: MutableMap<String, String>, modifier: IRequestModifier?): Pair<String, MutableMap<String, String>> {
if (modifier == null) return Pair(url, headers)
val modified = modifier.modifyRequest(url, headers)
return Pair(modified.url ?: url, modified.headers.toMutableMap())
}
fun tryHead(url: String, modifier: IRequestModifier? = null): Map<String, String>? {
ensureNotMainThread()
try {
val result = head(url);
val result = head(url, HashMap(), modifier);
if(result.isOk)
return result.getHeadersFlat();
else
@@ -141,12 +148,14 @@ open class ManagedHttpClient {
return Socket(websocket);
}
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
return execute(Request(url, "GET", null, headers));
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
return execute(Request(finalUrl, "GET", null, finalHeaders));
}
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
return execute(Request(url, "HEAD", null, headers));
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
return execute(Request(finalUrl, "HEAD", null, finalHeaders));
}
fun post(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
@@ -100,7 +100,7 @@ class SourcePluginDescriptor {
@FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, -1)
var checkForUpdates: Boolean = true;
@FormField(R.string.automatic_update_setting, FieldForm.TOGGLE, R.string.automatic_update_setting_description, 0)
var automaticUpdate: Boolean = false;
var automaticUpdate: Boolean = true;
@FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2)
var tabEnabled = TabEnabled();
@@ -920,6 +920,7 @@ class StateCasting {
if (videoSource != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
@@ -927,6 +928,7 @@ class StateCasting {
if (audioSource != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
@@ -968,8 +970,7 @@ class StateCasting {
val headers = masterContext.headers.clone()
headers["Content-Type"] = "application/vnd.apple.mpegurl";
val req = requestModifier?.modifyRequest(sourceUrl, mapOf())
val masterPlaylistResponse = _client.get(req?.url ?: sourceUrl, (req?.headers ?: mapOf()).toMutableMap())
val masterPlaylistResponse = _client.get(sourceUrl, mutableMapOf(), requestModifier)
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
@@ -1022,7 +1023,7 @@ class StateCasting {
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val response = _client.get(variantPlaylistRef.url)
val response = _client.get(variantPlaylistRef.url, mutableMapOf(), requestModifier)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string()
@@ -1059,7 +1060,7 @@ class StateCasting {
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val response = _client.get(mediaRendition.uri)
val response = _client.get(mediaRendition.uri, mutableMapOf(), requestModifier)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string()
@@ -1190,6 +1191,7 @@ class StateCasting {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
@@ -1267,6 +1269,7 @@ class StateCasting {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
@@ -1350,6 +1353,7 @@ class StateCasting {
if (videoSource != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
@@ -1357,6 +1361,7 @@ class StateCasting {
if (audioSource != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
@@ -71,16 +71,14 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
fun emit() : Boolean {
var handled = false;
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke();
}
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke();
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
handler.handler.invoke();
}
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke();
return handled;
}
@@ -88,16 +86,15 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() {
fun emit(value : T1): Boolean {
var handled = false;
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke(value);
}
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
handler.handler.invoke(value);
}
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke(value);
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke(value);
return handled;
}
@@ -106,16 +103,14 @@ class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() {
fun emit(value1 : T1, value2 : T2): Boolean {
var handled = false;
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke(value1, value2);
}
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke(value1, value2);
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
handler.handler.invoke(value1, value2);
}
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke(value1, value2);
return handled;
}
@@ -125,16 +120,14 @@ class Event3<T1, T2, T3>() : EventBase<((T1, T2, T3)->Unit), ((T1, T2, T3)->Bool
fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean {
var handled = false;
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke(value1, value2, value3);
}
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke(value1, value2, value3);
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
handler.handler.invoke(value1, value2, value3);
}
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke(value1, value2, value3);
return handled;
}
@@ -134,7 +134,7 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
var inputStream: InputStream? = null;
try {
val client = ManagedHttpClient();
val response = client.get(StateUpdate.APK_URL);
val response = client.get(StateUpdate.getApkUrl(_maxVersion));
if (response.isOk && response.body != null) {
inputStream = response.body.byteStream();
val dataLength = response.body.contentLength();
@@ -156,6 +156,18 @@ class VideoDownload {
var hasVideoRequestModifier: Boolean = false;
var hasAudioRequestModifier: Boolean = false;
// Transient: IRequestModifier is a runtime object from the JS plugin engine and cannot be
// serialized. After deserialization these are null - DownloadService must re-prepare to
// recapture them from the live plugin source (see needsReprepareForAuth).
@kotlinx.serialization.Transient
private var preparedVideoRequestModifier: IRequestModifier? = null;
@kotlinx.serialization.Transient
private var preparedAudioRequestModifier: IRequestModifier? = null;
val needsReprepareForAuth: Boolean get() =
(hasVideoRequestModifier && preparedVideoRequestModifier == null && videoSourceLive == null) ||
(hasAudioRequestModifier && preparedAudioRequestModifier == null && audioSourceLive == null);
var progress: Double = 0.0;
var isCancelled = false;
@@ -211,7 +223,7 @@ class VideoDownload {
this.requireAudioSource = targetBitrate != null; //TODO: May not be a valid check.. can only be determined after live fetch?
this.requiredCheck = optionalSources;
}
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, videoModifier: IRequestModifier? = null, audioModifier: IRequestModifier? = null) {
this.video = SerializedPlatformVideo.fromVideo(video);
this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf());
this.videoSource = if(videoSource is IVideoUrlSource) VideoUrlSource.fromUrlSource(videoSource) else null;
@@ -220,12 +232,22 @@ class VideoDownload {
this.audioSourceLive = if(audioSource is JSSource) audioSource else null;
this.subtitleSource = subtitleSource;
this.prepareTime = OffsetDateTime.now();
this.preparedVideoRequestModifier = videoModifier ?: (if (videoSource is JSSource && videoSource.hasRequestModifier) videoSource.getRequestModifier() else null);
this.preparedAudioRequestModifier = audioModifier ?: (if (audioSource is JSSource && audioSource.hasRequestModifier) audioSource.getRequestModifier() else null);
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier;
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier;
this.requiresLiveVideoSource = this.hasVideoRequestModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
this.requiresLiveAudioSource = this.hasAudioRequestModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
// Set modifier flags from either the source or an explicitly provided modifier
// (e.g. from the HLS picker, where the source is an HLSVariant, not JSSource).
// These flags are serialized and used by needsReprepareForAuth after restore.
this.hasVideoRequestModifier = preparedVideoRequestModifier != null;
this.hasAudioRequestModifier = preparedAudioRequestModifier != null;
// requiresLiveVideoSource means a live JSSource is needed at download time (for executors
// or DASH generation). Modifiers alone don't require a live source - they're already
// captured in preparedVideoRequestModifier and recaptured via needsReprepareForAuth.
val sourceHasVideoModifier = videoSource is JSSource && videoSource.hasRequestModifier;
val sourceHasAudioModifier = audioSource is JSSource && audioSource.hasRequestModifier;
this.requiresLiveVideoSource = sourceHasVideoModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
this.requiresLiveAudioSource = sourceHasAudioModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
this.targetVideoName = videoSource?.name;
this.targetAudioName = audioSource?.name;
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
@@ -321,8 +343,10 @@ class VideoDownload {
val videoSources = arrayListOf<IVideoSource>()
for (source in original.video.videoSources) {
if (source is IHLSManifestSource) {
val sourceModifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
if (sourceModifier != null) preparedVideoRequestModifier = sourceModifier
try {
val playlistResponse = client.get(source.url)
val playlistResponse = client.get(source.url, HashMap(), sourceModifier)
if (playlistResponse.isOk) {
val resolvedPlaylistUrl = playlistResponse.url
val playlistContent = playlistResponse.body?.string()
@@ -349,6 +373,8 @@ class VideoDownload {
if(vsource is JSSource) {
this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || vsource.hasRequestExecutor;
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (vsource is JSDashManifestRawSource && vsource.hasGenerate);
if (vsource.hasRequestModifier && preparedVideoRequestModifier == null)
preparedVideoRequestModifier = vsource.getRequestModifier()
}
if(vsource == null) {
@@ -370,8 +396,10 @@ class VideoDownload {
if (video is VideoUnMuxedSourceDescriptor) {
for (source in video.audioSources) {
if (source is IHLSManifestAudioSource) {
val sourceModifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
if (sourceModifier != null) preparedAudioRequestModifier = sourceModifier
try {
val playlistResponse = client.get(source.url)
val playlistResponse = client.get(source.url, HashMap(), sourceModifier)
if (playlistResponse.isOk) {
val resolvedPlaylistUrl = playlistResponse.url
val playlistContent = playlistResponse.body?.string()
@@ -406,6 +434,8 @@ class VideoDownload {
if(asource is JSSource) {
this.hasAudioRequestExecutor = this.hasAudioRequestExecutor || asource.hasRequestExecutor;
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate);
if (asource.hasRequestModifier && preparedAudioRequestModifier == null)
preparedAudioRequestModifier = asource.getRequestModifier()
}
if(asource == null) {
@@ -502,10 +532,16 @@ class VideoDownload {
}
}
val videoModifier = preparedVideoRequestModifier
if(actualVideoSource is IVideoUrlSource)
videoFileSize = when (videoSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> {
// HLS segments are concatenated into an MP4 file during download,
// so override the container for local playback/casting
videoOverrideContainer = "video/mp4";
downloadHlsSource(context, "Video", client, videoModifier, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
}
else -> downloadFileSource("Video", client, videoModifier, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
}
else if(actualVideoSource is JSDashManifestRawSource) {
if(actualAudioSource == null)
@@ -546,10 +582,16 @@ class VideoDownload {
}
}
val audioModifier = preparedAudioRequestModifier
if(actualAudioSource is IAudioUrlSource)
audioFileSize = when (audioSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> {
// HLS segments are concatenated into an MP4 file during download,
// so override the container for local playback/casting
audioOverrideContainer = "audio/mp4";
downloadHlsSource(context, "Audio", client, audioModifier, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
else -> downloadFileSource("Audio", client, audioModifier, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
else if(actualAudioSource is JSDashManifestRawAudioSource) {
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2);
@@ -663,15 +705,11 @@ class VideoDownload {
}
}
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, modifier: IRequestModifier?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if (targetFile.exists())
targetFile.delete()
var downloadedTotalLength = 0L
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier()
else
null
fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray {
val headers = mutableMapOf<String, String>()
@@ -685,17 +723,13 @@ class VideoDownload {
}
}
val modified = modifier?.modifyRequest(url, headers)
val finalUrl = modified?.url ?: url
val finalHeaders = modified?.headers?.toMutableMap() ?: headers
val resp = client.get(finalUrl, finalHeaders)
val resp = client.get(url, headers, modifier)
if (!resp.isOk) {
resp.body?.close()
throw IllegalStateException("Failed to download HLS resource ($finalUrl): HTTP ${resp.code}")
throw IllegalStateException("Failed to download HLS resource ($url): HTTP ${resp.code}")
}
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($finalUrl): Empty body")
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($url): Empty body")
val bytes = body.bytes()
body.close()
return bytes
@@ -710,12 +744,7 @@ class VideoDownload {
val segmentFiles = arrayListOf<File>()
try {
val playlistHeaders = mutableMapOf<String, String>()
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
val playlistResp = client.get(
modifiedPlaylistReq?.url ?: hlsUrl,
modifiedPlaylistReq?.headers?.toMutableMap() ?: playlistHeaders
)
val playlistResp = client.get(hlsUrl, mutableMapOf(), modifier)
check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" }
@@ -964,16 +993,7 @@ class VideoDownload {
Logger.i(TAG, "Downloading cue ${indexCounter}")
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
val modified = modifier?.modifyRequest(url, mapOf());
val data = if(executor != null)
executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: mapOf());
else {
val resp = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
}
val data = executeOrGet(client, executor, modifier, url)
fileStream.write(data, 0, data.size);
speedTracker.addWork(data.size.toLong());
written += data.size;
@@ -993,16 +1013,7 @@ class VideoDownload {
val t2 = cue2.groupValues[1];
val d2 = cue2.groupValues[2];
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
val modified2 = modifier?.modifyRequest(url, mapOf());
val data = if(executor != null)
executor.executeRequest("GET", modified2?.url ?: url2, null, modified2?.headers ?: mapOf());
else {
val resp = client.get(modified2?.url ?: url, modified2?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request2 failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
}
val data = executeOrGet(client, executor, modifier, url2)
fileStream2.write(data, 0, data.size);
speedTracker.addWork(data.size.toLong());
written2 += data.size;
@@ -1071,7 +1082,7 @@ class VideoDownload {
}
}
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
private fun downloadFileSource(name: String, client: ManagedHttpClient, modifier: IRequestModifier?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
@@ -1080,13 +1091,8 @@ class VideoDownload {
val sourceLength: Long?;
val fileStream = FileOutputStream(targetFile);
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier();
else
null;
try {
val head = client.tryHead(videoUrl);
val head = client.tryHead(videoUrl, modifier);
val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null };
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
{
@@ -1161,12 +1167,7 @@ class VideoDownload {
var lastSpeed: Long = 0;
val result = if (modifier != null) {
val modified = modifier.modifyRequest(url, mapOf())
client.get(modified.url!!, modified.headers.toMutableMap())
} else {
client.get(url)
}
val result = client.get(url, HashMap(), modifier)
if (!result.isOk) {
result.body?.close()
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
@@ -1379,13 +1380,12 @@ class VideoDownload {
var lastException: Throwable? = null;
val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}"));
val modified = modifier?.modifyRequest(url, headers);
while (retryCount <= 3) {
try {
val toRead = rangeEnd - rangeStart;
val req = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: headers);
val req = client.get(url, headers.toMutableMap(), modifier);
if (!req.isOk) {
val bodyString = req.body?.string()
req.body?.close()
@@ -1519,6 +1519,18 @@ class VideoDownload {
}
}
private fun executeOrGet(client: ManagedHttpClient, executor: JSRequestExecutor?, modifier: IRequestModifier?, url: String, headers: Map<String, String> = mapOf()): ByteArray {
if (executor != null) {
val modified = modifier?.modifyRequest(url, headers)
return executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: headers)
} else {
val resp = client.get(url, headers.toMutableMap(), modifier)
if (!resp.isOk)
throw IllegalStateException("Request failed for ($url) with code: ${resp.code}")
return resp.body!!.bytes()
}
}
companion object {
const val TAG = "VideoDownload";
const val GROUP_PLAYLIST = "Playlist";
@@ -238,6 +238,16 @@ class DownloadService : Service() {
download.targetBitrate = download.audioSource!!.bitrate.toLong();
download.audioSource = null;
}
// Force re-prepare if auth modifiers are needed but lost (e.g. after deserialization,
// since IRequestModifier is transient and cannot survive serialization).
// Must also clear sources so prepare() enters the source selection branches where
// modifiers are recaptured from the live plugin JSSource.
if(download.needsReprepareForAuth) {
Logger.w(TAG, "Video Download [${download.name}] needs re-prepare for auth modifiers");
download.videoDetails = null;
download.videoSource = null;
download.audioSource = null;
}
if(download.videoDetails == null || (!download.isVideoDownloadReady || !download.isAudioDownloadReady))
download.changeState(VideoDownload.State.PREPARING);
notifyDownload(download);
@@ -668,6 +668,9 @@ class StateApp {
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.seedUiFromDisk(context)
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build();
@@ -10,6 +10,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
@@ -341,8 +342,8 @@ class StateDownloads {
fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) {
download(VideoDownload(video, targetPixelcount, targetBitrate));
}
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
download(VideoDownload(video, videoSource, audioSource, subtitleSource));
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, videoModifier: IRequestModifier? = null, audioModifier: IRequestModifier? = null) {
download(VideoDownload(video, videoSource, audioSource, subtitleSource, videoModifier, audioModifier));
}
private fun download(videoState: VideoDownload, notify: Boolean = true) {
@@ -5,6 +5,7 @@ import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.Dispatchers
@@ -14,7 +15,97 @@ import java.io.File
import java.io.InputStream
import java.io.OutputStream
enum class UpdateUiState { NONE, AVAILABLE, DOWNLOADING, READY, FAILED }
class StateUpdate {
@Volatile var uiState: UpdateUiState = UpdateUiState.NONE
private set
@Volatile var uiVersion: Int = 0
private set
@Volatile var uiProgress: Int = 0
private set
@Volatile var uiIndeterminate: Boolean = true
private set
@Volatile var uiApkFile: File? = null
private set
@Volatile var uiError: String? = null
private set
val onUiChanged = Event0()
fun setUiAvailable(version: Int) {
uiState = UpdateUiState.AVAILABLE
uiVersion = version
uiError = null
onUiChanged.emit()
}
fun setUiDownloading(version: Int, progress: Int, indeterminate: Boolean) {
uiState = UpdateUiState.DOWNLOADING
uiVersion = version
uiProgress = progress
uiIndeterminate = indeterminate
uiError = null
onUiChanged.emit()
}
fun setUiReady(version: Int, apkFile: File) {
uiState = UpdateUiState.READY
uiVersion = version
uiApkFile = apkFile
uiError = null
onUiChanged.emit()
}
fun setUiFailed(version: Int, error: String?) {
uiState = UpdateUiState.FAILED
uiVersion = version
uiError = error
onUiChanged.emit()
}
fun clearUi() {
uiState = UpdateUiState.NONE
uiVersion = 0
uiProgress = 0
uiIndeterminate = true
uiApkFile = null
uiError = null
onUiChanged.emit()
}
fun seedUiFromDisk(context: Context) {
if (uiState != UpdateUiState.NONE) return
try {
val dir = File(context.filesDir, "updates")
if (!dir.exists()) return
val abi = try { DESIRED_ABI } catch (t: Throwable) { return }
val prefix = "app-$abi-"
val suffix = ".apk"
val candidates = dir.listFiles { f ->
f.isFile && f.name.startsWith(prefix) && f.name.endsWith(suffix)
} ?: return
var bestVersion = BuildConfig.VERSION_CODE
var bestFile: File? = null
for (f in candidates) {
val versionStr = f.name.removePrefix(prefix).removeSuffix(suffix)
val v = versionStr.toIntOrNull() ?: continue
if (v > bestVersion && f.length() > 0L) {
bestVersion = v
bestFile = f
}
}
val ready = bestFile
if (ready != null) {
Logger.i(TAG, "Seeding UI ready from disk: v=$bestVersion file=${ready.absolutePath}")
setUiReady(bestVersion, ready)
}
} catch (t: Throwable) {
Logger.w(TAG, "Failed to seed UI from disk", t)
}
}
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
try {
val client = ManagedHttpClient();
@@ -97,16 +188,16 @@ class StateUpdate {
throw Exception("App is not compatible. Supported ABIS: ${Build.SUPPORTED_ABIS.joinToString()}}.");
};
val VERSION_URL = if (BuildConfig.IS_UNSTABLE_BUILD) {
"https://releases.grayjay.app/version-unstable.txt"
"https://rel.grayjay.app/version-unstable.txt"
} else {
"https://releases.grayjay.app/version.txt"
"https://rel.grayjay.app/version.txt"
}
val APK_URL = if (BuildConfig.IS_UNSTABLE_BUILD) {
"https://releases.grayjay.app/app-$DESIRED_ABI-release-unstable.apk"
fun getApkUrl(version: Int): String = if (BuildConfig.IS_UNSTABLE_BUILD) {
"https://rel.grayjay.app/$version/app-$DESIRED_ABI-release-unstable.apk"
} else {
"https://releases.grayjay.app/app-$DESIRED_ABI-release.apk"
"https://rel.grayjay.app/$version/app-$DESIRED_ABI-release.apk"
}
val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs";
val CHANGELOG_BASE_URL = "https://rel.grayjay.app/changelogs";
fun getApkFile(context: Context, version: Int): File {
val dir = File(context.filesDir, "updates");
@@ -136,4 +227,4 @@ class StateUpdate {
}
}
}
}
}
@@ -0,0 +1,158 @@
package com.futo.platformplayer.views.announcements
import android.content.Context
import android.content.Intent
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UpdateDownloadService
import com.futo.platformplayer.UpdateInstaller
import com.futo.platformplayer.UpdateNotificationManager
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import com.futo.platformplayer.states.UpdateUiState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class UpdateBannerView : LinearLayout {
private val _root: FrameLayout
private val _iconUpdate: ImageView
private val _textTitle: TextView
private val _progressBar: ProgressBar
private val _buttonAction: FrameLayout
private val _textAction: TextView
private val _scope: CoroutineScope?
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_update_banner, this)
_scope = findViewTreeLifecycleOwner()?.lifecycleScope ?: StateApp.instance.scopeOrNull
_root = findViewById(R.id.root)
_iconUpdate = findViewById(R.id.icon_update)
_textTitle = findViewById(R.id.text_title)
_progressBar = findViewById(R.id.update_banner_progress)
_buttonAction = findViewById(R.id.button_action)
_textAction = findViewById(R.id.text_action)
_buttonAction.setOnClickListener {
onActionClicked()
}
refresh()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
StateUpdate.instance.onUiChanged.subscribe(this) {
_scope?.launch(Dispatchers.Main) {
refresh()
}
}
refresh()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
StateUpdate.instance.onUiChanged.remove(this)
}
private fun onActionClicked() {
val st = StateUpdate.instance
when (st.uiState) {
UpdateUiState.READY -> {
val apk = st.uiApkFile ?: return
UpdateNotificationManager.cancelAll(context)
UpdateInstaller.startInstall(context, st.uiVersion, apk)
}
UpdateUiState.FAILED -> {
if (st.uiVersion == 0) return
val intent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, st.uiVersion)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (t: Throwable) {
Logger.w(TAG, "Retry start service failed", t)
}
}
UpdateUiState.AVAILABLE -> {
if (st.uiVersion == 0) return
val intent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, st.uiVersion)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (t: Throwable) {
Logger.w(TAG, "Download start service failed", t)
}
}
UpdateUiState.DOWNLOADING -> {}
UpdateUiState.NONE -> {}
}
}
private fun refresh() {
val st = StateUpdate.instance
val gateOpen = Settings.instance.autoUpdate.shouldBackgroundDownload
val visible = gateOpen && st.uiState != UpdateUiState.NONE
if (!visible) {
_root.visibility = View.GONE
return
}
_root.visibility = View.VISIBLE
when (st.uiState) {
UpdateUiState.AVAILABLE -> {
_textTitle.text = "Update v${st.uiVersion}"
_progressBar.visibility = View.GONE
_textAction.text = "Download"
_buttonAction.visibility = View.VISIBLE
}
UpdateUiState.DOWNLOADING -> {
if (st.uiIndeterminate) {
_textTitle.text = "Downloading v${st.uiVersion}"
_progressBar.isIndeterminate = true
} else {
_textTitle.text = "Downloading v${st.uiVersion} - ${st.uiProgress}%"
_progressBar.isIndeterminate = false
_progressBar.progress = st.uiProgress
}
_progressBar.visibility = View.VISIBLE
_buttonAction.visibility = View.GONE
}
UpdateUiState.READY -> {
_textTitle.text = "Ready v${st.uiVersion}"
_progressBar.visibility = View.GONE
_textAction.text = "Install"
_buttonAction.visibility = View.VISIBLE
}
UpdateUiState.FAILED -> {
_textTitle.text = "Update failed"
_progressBar.visibility = View.GONE
_textAction.text = "Retry"
_buttonAction.visibility = View.VISIBLE
}
UpdateUiState.NONE -> {
_root.visibility = View.GONE
}
}
}
companion object {
const val TAG = "UpdateBannerView"
}
}
+10 -1
View File
@@ -20,11 +20,20 @@
app:layout_constraintRight_toRightOf="parent"
tools:layout="@layout/fragment_overview_top_bar" />
<com.futo.platformplayer.views.announcements.UpdateBannerView
android:id="@+id/update_banner"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toBottomOf="@id/fragment_top_bar"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_main"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/fragment_top_bar"
app:layout_constraintTop_toBottomOf="@id/update_banner"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
+2 -2
View File
@@ -7,7 +7,7 @@
android:paddingEnd="12dp"
android:background="@drawable/background_pill"
android:layout_marginEnd="6dp"
android:layout_marginTop="17dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:id="@+id/root">
<LinearLayout
@@ -36,4 +36,4 @@
tools:text="Tag text" />
</LinearLayout>
</FrameLayout>
</FrameLayout>
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/root">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/background_16_round_4dp"
android:layout_marginLeft="10dp"
android:layout_marginTop="6dp"
android:layout_marginRight="10dp"
android:layout_marginBottom="0dp"
android:minHeight="40dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingLeft="12dp"
android:paddingTop="6dp"
android:paddingRight="8dp"
android:paddingBottom="6dp">
<ImageView android:id="@+id/icon_update"
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_update"
android:layout_marginRight="10dp"
android:alpha="0.9"
android:importantForAccessibility="no" />
<TextView android:id="@+id/text_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
tools:text="Downloading v123 - 42%"
android:fontFamily="@font/inter_semibold"
android:textSize="14sp"
android:textColor="@color/white"
android:ellipsize="end"
android:maxLines="1" />
<ProgressBar android:id="@+id/update_banner_progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="78dp"
android:layout_height="4dp"
android:layout_marginLeft="10dp"
android:layout_marginRight="4dp"
android:max="100"
android:visibility="gone"
tools:visibility="visible" />
<FrameLayout android:id="@+id/button_action"
android:layout_width="wrap_content"
android:layout_height="28dp"
android:layout_marginLeft="10dp"
android:background="@drawable/background_button_primary_round_4dp">
<TextView android:id="@+id/text_action"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center"
tools:text="Install"
android:fontFamily="@font/inter_regular"
android:textSize="12sp"
android:textColor="@color/white"
android:paddingLeft="13dp"
android:paddingRight="13dp" />
</FrameLayout>
</LinearLayout>
</FrameLayout>