From 1c9becc2bad6a7ef7c60f8d09c820b746ddae04f Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Tue, 2 Dec 2025 15:21:22 -0600 Subject: [PATCH 01/20] Replace finalize with manual close for jsrequestexecutor, incognito icon change, show explanation for incognito --- .../java/com/futo/platformplayer/Settings.kt | 2 ++ .../platforms/js/models/JSRequestExecutor.kt | 34 +++++++++++++++--- .../platformplayer/casting/StateCasting.kt | 4 +++ .../platformplayer/downloads/VideoDownload.kt | 4 ++- .../bottombar/MenuBottomBarFragment.kt | 10 ++++++ .../video/datasources/JSHttpDataSource.java | 12 +++++++ app/src/main/res/drawable/incognito.png | Bin 0 -> 13935 bytes .../main/res/drawable/incognito_purple.png | Bin 0 -> 20688 bytes app/src/main/res/layout/activity_main.xml | 3 +- .../layout/fragment_overview_bottom_bar.xml | 2 +- 10 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 app/src/main/res/drawable/incognito.png create mode 100644 app/src/main/res/drawable/incognito_purple.png diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index c5456bc4..6bfedcca 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -1052,6 +1052,8 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7) var polycentricLocalCache: Boolean = true; + + var showPrivacyModeDialog: Boolean = true; } @FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt index ed428790..5a41d0a2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt @@ -17,11 +17,14 @@ import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.invokeV8Void import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateDeveloper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import java.util.Base64 -class JSRequestExecutor { +class JSRequestExecutor: AutoCloseable { private val _plugin: JSClient; private val _config: IV8PluginConfig; private var _executor: V8ValueObject; @@ -29,6 +32,9 @@ class JSRequestExecutor { private val hasCleanup: Boolean; + private var _cleanLock = Any(); + private var _cleaned: Boolean = false; + constructor(plugin: JSClient, executor: V8ValueObject) { this._plugin = plugin; this._executor = executor; @@ -102,8 +108,12 @@ class JSRequestExecutor { open fun cleanup() { - if (!hasCleanup || _executor.isClosed) - return; + synchronized(_cleanLock) { + if (!hasCleanup || _executor.isClosed || _cleaned) + return; + _cleaned = true; + } + Logger.i("JSRequestExecutor", "JSRequestExecutor cleanup requested"); _plugin.busy { if(_plugin is DevJSClient) StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") { @@ -125,9 +135,25 @@ class JSRequestExecutor { } } - protected fun finalize() { + override fun close() { cleanup(); } + + fun closeAsync() { + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){ + try { + close(); + } + catch(ex: Throwable) { + Logger.e("JSRequestExecutor", "Cleanup failed"); + } + } + } + + /* + protected fun finalize() { + cleanup(); + }*/ } //TODO: are these available..? diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 269a73b8..0404dbeb 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -1280,10 +1280,14 @@ abstract class StateCasting { } if (audioSource != null && audioSource.hasRequestExecutor) { + val oldExecutor = _audioExecutor; + oldExecutor?.closeAsync(); _audioExecutor = audioSource.getRequestExecutor() } if (videoSource != null && videoSource.hasRequestExecutor) { + val oldExecutor = _videoExecutor; + oldExecutor?.closeAsync(); _videoExecutor = videoSource.getRequestExecutor() } diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt index 0881bb6e..3b727d21 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -865,6 +865,7 @@ class VideoDownload { val sourceLength: Long?; val fileStream = FileOutputStream(targetFile); + var executor: JSRequestExecutor? = null; try{ var manifest = source.manifest; if(source.hasGenerate) @@ -881,7 +882,7 @@ class VideoDownload { if(foundCues.count() <= 0) throw IllegalStateException("No Cues found in manifest (unsupported dash?)"); - val executor = if(source is JSSource && source.hasRequestExecutor) + executor = if(source is JSSource && source.hasRequestExecutor) source.getRequestExecutor(); else null; @@ -940,6 +941,7 @@ class VideoDownload { } finally { fileStream.close(); + executor?.closeAsync() } return sourceLength!!; } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt index a51e7620..a6dc02ae 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt @@ -154,6 +154,16 @@ class MenuBottomBarFragment : MainActivityFragment() { else { StateApp.instance.setPrivacyMode(true); UIDialogs.appToast("Privacy mode enabled"); + + UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode", + "All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0, + UIDialogs.Action("Don't show again", { + Settings.instance.other.showPrivacyModeDialog = false; + Settings.instance.save(); + }, UIDialogs.ActionStyle.NONE), + UIDialogs.Action("Understood", { + + }, UIDialogs.ActionStyle.PRIMARY)); } } diff --git a/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java b/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java index 859c1b2f..9c106a9a 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java +++ b/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java @@ -111,6 +111,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { * @return This factory. */ public Factory setRequestExecutor(@Nullable JSRequestExecutor requestExecutor) { + JSRequestExecutor oldExecutor = this.requestExecutor; + if(oldExecutor != null) { + oldExecutor.closeAsync(); + } this.requestExecutor = requestExecutor; return this; } @@ -123,6 +127,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { * @return This factory. */ public Factory setRequestExecutor2(@Nullable JSRequestExecutor requestExecutor) { + JSRequestExecutor oldExecutor = this.requestExecutor2; + if(oldExecutor != null) { + oldExecutor.closeAsync(); + } this.requestExecutor2 = requestExecutor; return this; } @@ -508,6 +516,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { @Override public void close() throws HttpDataSourceException { + if(requestExecutor != null) + requestExecutor.closeAsync(); + if(requestExecutor2 != null) + requestExecutor2.closeAsync(); try { @Nullable InputStream inputStream = this.inputStream; if (inputStream != null) { diff --git a/app/src/main/res/drawable/incognito.png b/app/src/main/res/drawable/incognito.png new file mode 100644 index 0000000000000000000000000000000000000000..7321cdc571b4e598dacefa48bfceb538047b88b4 GIT binary patch literal 13935 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index df3abc69..9adf1cd4 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -77,12 +77,13 @@ android:layout_width="50dp" android:layout_height="50dp" android:contentDescription="@string/cd_incognito_button" - android:src="@drawable/ic_disabled_visible_purple" + android:src="@drawable/incognito_purple" android:background="@drawable/background_button_round_black" android:scaleType="fitCenter" android:visibility="visible" android:layout_marginLeft="10dp" android:layout_marginBottom="10dp" + android:padding="8dp" android:elevation="50dp" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintBottom_toTopOf="@id/toast_view" /> diff --git a/app/src/main/res/layout/fragment_overview_bottom_bar.xml b/app/src/main/res/layout/fragment_overview_bottom_bar.xml index 4fde34a4..070130bf 100644 --- a/app/src/main/res/layout/fragment_overview_bottom_bar.xml +++ b/app/src/main/res/layout/fragment_overview_bottom_bar.xml @@ -75,7 +75,7 @@ + android:src="@drawable/incognito" /> From def39ba397ccf26f1815dfb48584e025f1053ae3 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Tue, 2 Dec 2025 17:11:12 -0600 Subject: [PATCH 02/20] Diff loop icon, allow loop1 in playlist, fix queue clear on opening video on a channel page --- .../fragment/mainactivity/main/ChannelFragment.kt | 12 +++++++++--- .../platformplayer/views/video/FutoVideoPlayer.kt | 10 +++++----- app/src/main/res/drawable/ic_repeat_one.xml | 9 +++++++++ app/src/main/res/drawable/ic_repeat_one_active.xml | 9 +++++++++ app/src/main/res/layout/video_player_ui.xml | 2 +- .../main/res/layout/video_player_ui_fullscreen.xml | 2 +- 6 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 app/src/main/res/drawable/ic_repeat_one.xml create mode 100644 app/src/main/res/drawable/ic_repeat_one_active.xml diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt index 91e6aaa3..541d7c7c 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.lifecycleScope import androidx.viewpager2.widget.ViewPager2 import com.bumptech.glide.Glide import com.futo.platformplayer.R +import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.api.media.PlatformID @@ -55,6 +56,7 @@ import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.subscriptions.SubscribeButton +import com.futo.platformplayer.withTimestamp import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.PolycentricProfile import com.futo.polycentric.core.toURLInfoSystemLinkUrl @@ -198,8 +200,12 @@ class ChannelFragment : MainFragment() { adapter.onContentClicked.subscribe { v, _ -> when (v) { is IPlatformVideo -> { - StatePlayer.instance.clearQueue() - fragment.navigate(v).maximizeVideoDetail() + //StatePlayer.instance.clearQueue() + if (StatePlayer.instance.hasQueue) { + StatePlayer.instance.insertToQueue(v, true); + } else { + fragment.navigate(v).maximizeVideoDetail(); + } } is IPlatformPlaylist -> { @@ -244,7 +250,7 @@ class ChannelFragment : MainFragment() { adapter.onContentUrlClicked.subscribe { url, contentType -> when (contentType) { ContentType.MEDIA -> { - StatePlayer.instance.clearQueue() + StatePlayer.instance.clearQueue(); fragment.navigate(url).maximizeVideoDetail() } diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt index af666ca9..b62c8f50 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt @@ -489,7 +489,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase { StatePlayer.instance.onQueueChanged.subscribe(this) { CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) { - setLoopVisible(!StatePlayer.instance.hasQueue) + //setLoopVisible(!StatePlayer.instance.hasQueue) updateNextPrevious(); } } @@ -886,12 +886,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase { } fun updateLoopVideoUI() { if(StatePlayer.instance.loopVideo) { - _control_loop.setImageResource(R.drawable.ic_loop_active); - _control_loop_fullscreen.setImageResource(R.drawable.ic_loop_active); + _control_loop.setImageResource(R.drawable.ic_repeat_one_active); + _control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one_active); } else { - _control_loop.setImageResource(R.drawable.ic_loop); - _control_loop_fullscreen.setImageResource(R.drawable.ic_loop); + _control_loop.setImageResource(R.drawable.ic_repeat_one); + _control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one); } } diff --git a/app/src/main/res/drawable/ic_repeat_one.xml b/app/src/main/res/drawable/ic_repeat_one.xml new file mode 100644 index 00000000..48a96fbe --- /dev/null +++ b/app/src/main/res/drawable/ic_repeat_one.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_repeat_one_active.xml b/app/src/main/res/drawable/ic_repeat_one_active.xml new file mode 100644 index 00000000..4556487e --- /dev/null +++ b/app/src/main/res/drawable/ic_repeat_one_active.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/video_player_ui.xml b/app/src/main/res/layout/video_player_ui.xml index 486936a6..8e31747c 100644 --- a/app/src/main/res/layout/video_player_ui.xml +++ b/app/src/main/res/layout/video_player_ui.xml @@ -65,7 +65,7 @@ android:scaleType="fitCenter" android:clickable="true" android:padding="12dp" - app:srcCompat="@drawable/ic_loop" /> + app:srcCompat="@drawable/ic_repeat_one" /> + app:srcCompat="@drawable/ic_repeat_one" /> Date: Wed, 3 Dec 2025 12:04:29 +0100 Subject: [PATCH 03/20] Potential fix for issue where cast icon doesn't properly turn blue at the right moment. --- .../futo/platformplayer/views/casting/CastButton.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt index acffc619..2718f482 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt @@ -8,6 +8,7 @@ import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.casting.CastConnectionState +import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 @@ -22,18 +23,16 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton { visibility = View.GONE; } - StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> - updateCastState(); + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { d, _ -> + updateCastState(d); }; - updateCastState(); + updateCastState(StateCasting.instance.activeDevice); } } - private fun updateCastState() { + private fun updateCastState(d: CastingDevice?) { val c = context ?: return; - val d = StateCasting.instance.activeDevice; - val activeColor = ContextCompat.getColor(c, R.color.colorPrimary); val connectingColor = ContextCompat.getColor(c, R.color.gray_c3); val inactiveColor = ContextCompat.getColor(c, R.color.white); From 70502a7651ca158a726e6f1baca54493ca270b15 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Wed, 3 Dec 2025 12:12:15 +0100 Subject: [PATCH 04/20] casting: set connectionState to correct value when disconnected --- .../java/com/futo/platformplayer/casting/CastingDeviceExp.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt index 1560eff1..84d96e02 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt @@ -239,7 +239,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() { } DeviceConnectionState.Disconnected -> { - connectionState = CastConnectionState.CONNECTING + connectionState = CastConnectionState.DISCONNECTED onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED) } } @@ -268,4 +268,4 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() { companion object { private val TAG = "CastingDeviceExp" } -} \ No newline at end of file +} From eba995f87d5eebad4303f6ecd0907fcc0a186b55 Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 3 Dec 2025 14:09:33 +0100 Subject: [PATCH 05/20] Added support for casting local media content. --- .../server/handlers/HttpContentUriHandler.kt | 317 ++++++++++++++++++ .../models/video/LocalPlatformVideoDetails.kt | 4 +- .../models/sources/LocalAudioContentSource.kt | 4 +- .../models/sources/LocalVideoContentSource.kt | 4 +- .../platformplayer/casting/StateCasting.kt | 49 +++ .../platformplayer/states/StateLibrary.kt | 7 +- 6 files changed, 377 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpContentUriHandler.kt diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpContentUriHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpContentUriHandler.kt new file mode 100644 index 00000000..6407e552 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpContentUriHandler.kt @@ -0,0 +1,317 @@ +package com.futo.platformplayer.api.http.server.handlers + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.MediaStore +import android.provider.OpenableColumns +import com.futo.platformplayer.api.http.server.HttpContext +import com.futo.platformplayer.api.http.server.HttpHeaders +import com.futo.platformplayer.logging.Logger +import java.io.FileNotFoundException +import java.io.InputStream +import java.io.OutputStream +import java.text.SimpleDateFormat +import java.util.* + +class HttpContentUriHandler( + method: String, + path: String, + private val contentResolver: ContentResolver, + private val uri: Uri, + private val explicitContentType: String? = null +) : HttpHandler(method, path) { + + override fun handle(httpContext: HttpContext) { + val resolver = contentResolver + val requestHeaders = httpContext.headers + val responseHeaders = this.headers.clone() + + val meta = try { + queryMetadata(resolver, uri) + } catch (e: Exception) { + Logger.e(TAG, "Failed to query metadata for $uri", e) + httpContext.respondCode(404, responseHeaders) + return + } + + val contentType = explicitContentType + ?: resolver.getType(uri) + ?: "application/octet-stream" + responseHeaders["Content-Type"] = contentType + + meta.lastModifiedMillis?.let { lastModified -> + responseHeaders["Last-Modified"] = httpDateFormat.format(Date(lastModified)) + + val ifModifiedSinceHeader = requestHeaders["If-Modified-Since"] + if (ifModifiedSinceHeader != null) { + val ifModifiedSince = try { + httpDateFormat.parse(ifModifiedSinceHeader) + } catch (_: Exception) { + null + } + + if (ifModifiedSince != null && lastModified <= ifModifiedSince.time) { + httpContext.respondCode(304, responseHeaders) + return + } + } + } + + val safeName = (meta.displayName ?: "content.bin").replace("\"", "\\\"") + responseHeaders["Content-Disposition"] = "attachment; filename=\"$safeName\"" + + val length = meta.size + if (length == null) { + Logger.i(TAG, "Streaming $uri with unknown length; Range not supported") + responseHeaders.remove("Content-Length") + responseHeaders.remove("Content-Range") + responseHeaders.remove("Accept-Ranges") + + stream( + httpContext = httpContext, + resolver = resolver, + uri = uri, + statusCode = 200, + headers = responseHeaders, + start = null, + length = null + ) + return + } + + responseHeaders["Accept-Ranges"] = "bytes" + + val rangeHeader = requestHeaders["Range"] + if (rangeHeader.isNullOrBlank()) { + responseHeaders["Content-Length"] = length.toString() + Logger.i(TAG, "Sending full content for $uri, length=$length") + + stream( + httpContext = httpContext, + resolver = resolver, + uri = uri, + statusCode = 200, + headers = responseHeaders, + start = 0L, + length = length + ) + return + } + + val range = parseRange(rangeHeader, length) + if (range == null) { + Logger.w(TAG, "Invalid Range '$rangeHeader' for $uri (length=$length)") + responseHeaders["Content-Range"] = "bytes */$length" + httpContext.respondCode(416, responseHeaders) + return + } + + val start = range.first + val endInclusive = range.last + val bytesToSend = endInclusive - start + 1 + + responseHeaders["Content-Range"] = "bytes $start-$endInclusive/$length" + responseHeaders["Content-Length"] = bytesToSend.toString() + Logger.i(TAG, "Sending range $start-$endInclusive (length=$bytesToSend) of $length for $uri") + + stream( + httpContext = httpContext, + resolver = resolver, + uri = uri, + statusCode = 206, + headers = responseHeaders, + start = start, + length = bytesToSend + ) + } + + data class ContentMeta( + val displayName: String?, + val size: Long?, + val lastModifiedMillis: Long? + ) + + private fun queryMetadata(resolver: ContentResolver, uri: Uri): ContentMeta { + var displayName: String? = null + var size: Long? = null + var lastModifiedMillis: Long? = null + + val projection = arrayOf( + OpenableColumns.DISPLAY_NAME, + OpenableColumns.SIZE, + MediaStore.MediaColumns.DATE_MODIFIED, + MediaStore.MediaColumns.DATE_ADDED + ) + + resolver.query(uri, projection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1 && !cursor.isNull(nameIndex)) { + displayName = cursor.getString(nameIndex) + } + + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) { + val s = cursor.getLong(sizeIndex) + if (s >= 0) size = s // -1 means unknown + } + + val dateModifiedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED) + if (dateModifiedIndex != -1 && !cursor.isNull(dateModifiedIndex)) { + val seconds = cursor.getLong(dateModifiedIndex) + if (seconds > 0) { + lastModifiedMillis = seconds * 1000L + } + } + + if (lastModifiedMillis == null) { + val dateAddedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED) + if (dateAddedIndex != -1 && !cursor.isNull(dateAddedIndex)) { + val seconds = cursor.getLong(dateAddedIndex) + if (seconds > 0) { + lastModifiedMillis = seconds * 1000L + } + } + } + } + } + + if (size == null) { + try { + resolver.openAssetFileDescriptor(uri, "r")?.use { afd -> + val assetLen = afd.length + if (assetLen >= 0) { + size = assetLen + } + } + } catch (_: Exception) { } + } + + return ContentMeta(displayName = displayName, size = size, lastModifiedMillis = lastModifiedMillis) + } + + private fun parseRange(header: String, totalLength: Long): LongRange? { + if (totalLength <= 0L) return null + + val prefix = "bytes=" + if (!header.startsWith(prefix, ignoreCase = true)) return null + + val spec = header.substring(prefix.length).trim() + if (spec.isEmpty()) return null + + if (spec.contains(",")) return null + + val dashIndex = spec.indexOf('-') + if (dashIndex < 0) return null + + val startPart = spec.substring(0, dashIndex).trim() + val endPart = spec.substring(dashIndex + 1).trim() + + return when { + startPart.isNotEmpty() -> { + val start = startPart.toLongOrNull() ?: return null + if (start < 0 || start >= totalLength) return null + + val end = if (endPart.isNotEmpty()) { + val rawEnd = endPart.toLongOrNull() ?: return null + if (rawEnd < start) return null + rawEnd.coerceAtMost(totalLength - 1) + } else { + totalLength - 1 + } + + start..end + } + + endPart.isNotEmpty() -> { + val suffixLen = endPart.toLongOrNull() ?: return null + if (suffixLen <= 0L) return null + + if (suffixLen >= totalLength) { + 0L..(totalLength - 1) + } else { + val start = totalLength - suffixLen + val end = totalLength - 1 + start..end + } + } + + else -> null + } + } + + private fun stream(httpContext: HttpContext, resolver: ContentResolver, uri: Uri, statusCode: Int, headers: HttpHeaders, start: Long?, length: Long?) { + try { + val input = resolver.openInputStream(uri) + if (input == null) { + Logger.w(TAG, "Content not found: $uri") + httpContext.respondCode(404, headers) + return + } + + input.use { inputStream -> + httpContext.respond(statusCode, headers) { outputStream -> + try { + val offset = start ?: 0L + if (offset > 0L) { + skipFully(inputStream, offset) + } + copyStream(inputStream, outputStream, length) + outputStream.flush() + } catch (e: Exception) { + Logger.e(TAG, "Error while streaming $uri (start=$start, length=$length)", e) + } + } + } + } catch (e: FileNotFoundException) { + Logger.w(TAG, "Content not found: $uri", e) + httpContext.respondCode(404, headers) + } catch (e: Exception) { + Logger.e(TAG, "Failed to open stream for $uri", e) + httpContext.respondCode(500, headers) + } + } + + private fun copyStream(input: InputStream, output: OutputStream, limit: Long?) { + val buffer = ByteArray(8192) + if (limit == null) { + while (true) { + val read = input.read(buffer) + if (read < 0) break + output.write(buffer, 0, read) + } + } else { + var remaining = limit + while (remaining > 0L) { + val toRead = remaining.coerceAtMost(buffer.size.toLong()).toInt() + val read = input.read(buffer, 0, toRead) + if (read < 0) break + output.write(buffer, 0, read) + remaining -= read.toLong() + } + } + } + + private fun skipFully(input: InputStream, bytesToSkip: Long) { + var remaining = bytesToSkip + while (remaining > 0L) { + val skipped = input.skip(remaining) + if (skipped <= 0L) { + val b = input.read() + if (b == -1) break + remaining -= 1L + } else { + remaining -= skipped + } + } + } + + companion object { + private const val TAG = "HttpContentUriHandler" + + private val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT") + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt index 52659b46..98dc3524 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt @@ -73,10 +73,10 @@ open class LocalVideoDetails( override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false) (LocalVideoUnMuxedSourceDescriptor( arrayOf(), - arrayOf(LocalAudioContentSource(url, mimeType ?: "", name)) + arrayOf(LocalAudioContentSource(url, mimeType ?: "", name, duration)) )) else (LocalVideoMuxedSourceDescriptor( - LocalVideoContentSource(url, mimeType ?: "", name) + LocalVideoContentSource(url, mimeType ?: "", name, duration) )) ); override val preview: ISerializedVideoSourceDescriptor? = null; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt index 06f1c50c..23f268f2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt @@ -23,10 +23,10 @@ class LocalAudioContentSource : IAudioSource { var contentUrl: String; - constructor(contentUrl: String, mime: String, name: String? = null) { + constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) { this.name = name ?: "File"; container = mime; - duration = 0; + this.duration = duration; this.contentUrl = contentUrl; } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt index d8507fab..e8b37364 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt @@ -22,12 +22,12 @@ class LocalVideoContentSource: IVideoSource { var contentUrl: String; - constructor(contentUrl: String, mime: String, name: String? = null) { + constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) { this.name = name ?: "File"; width = 0; height = 0; container = mime; - duration = 0; + this.duration = duration; this.contentUrl = contentUrl; } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 0404dbeb..e531354a 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -6,6 +6,7 @@ import android.content.Context import android.os.Looper import android.util.Log import androidx.annotation.OptIn +import androidx.core.net.toUri import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.R import com.futo.platformplayer.Settings @@ -14,6 +15,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.server.HttpHeaders import com.futo.platformplayer.api.http.server.ManagedHttpServer import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler +import com.futo.platformplayer.api.http.server.handlers.HttpContentUriHandler import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler @@ -34,6 +36,8 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif 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.api.media.platforms.js.models.sources.JSSource +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource import com.futo.platformplayer.awaitCancelConverted import com.futo.platformplayer.builders.DashBuilder import com.futo.platformplayer.models.CastingDeviceInfo @@ -371,6 +375,12 @@ abstract class StateCasting { } else if (audioSource is LocalAudioSource) { Logger.i(TAG, "Casting as local audio"); castLocalAudio(video, audioSource, resumePosition, speed); + } else if (videoSource is LocalVideoContentSource) { + Logger.i(TAG, "Casting as local video"); + castLocalVideo(contentResolver, video, videoSource, resumePosition, speed); + } else if (audioSource is LocalAudioContentSource) { + Logger.i(TAG, "Casting as local audio"); + castLocalAudio(contentResolver, video, audioSource, resumePosition, speed); } else if (videoSource is JSDashManifestRawSource) { Logger.i(TAG, "Casting as JSDashManifestRawSource video"); castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading); @@ -461,6 +471,45 @@ abstract class StateCasting { } return true; } + + private fun castLocalVideo(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: LocalVideoContentSource, resumePosition: Double, speed: Double?) : List { + val ad = activeDevice ?: return listOf(); + + val url = getLocalUrl(ad); + val id = UUID.randomUUID(); + val videoPath = "/video-${id}" + val videoUrl = url + videoPath; + + _castServer.addHandlerWithAllowAllOptions( + HttpContentUriHandler("GET", videoPath, contentResolver, videoSource.contentUrl.toUri()) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + + Logger.i(TAG, "Casting local video (videoUrl: $videoUrl)."); + ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); + + return listOf(videoUrl); + } + + private fun castLocalAudio(contentResolver: ContentResolver, video: IPlatformVideoDetails, audioSource: LocalAudioContentSource, resumePosition: Double, speed: Double?) : List { + val ad = activeDevice ?: return listOf(); + + val url = getLocalUrl(ad); + val id = UUID.randomUUID(); + val audioPath = "/audio-${id}" + val audioUrl = url + audioPath; + + _castServer.addHandlerWithAllowAllOptions( + HttpContentUriHandler("GET", audioPath, contentResolver, audioSource.contentUrl.toUri()) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + + Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl)."); + ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); + + return listOf(audioUrl); + } + private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt index 9176b8ae..06f7dfb3 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt @@ -344,7 +344,8 @@ class StateLibrary { MediaStore.Video.Media.DISPLAY_NAME, MediaStore.Video.Media.DATE_ADDED, MediaStore.Video.Media.MIME_TYPE, - MediaStore.Video.Media.BUCKET_DISPLAY_NAME + MediaStore.Video.Media.BUCKET_DISPLAY_NAME, + MediaStore.Video.Media.DURATION ); val PROJECTION_MEDIA = arrayOf( MediaStore.Audio.Media._ID, //0 @@ -515,6 +516,8 @@ class StateLibrary { val date = cursor.getLong(2); val contentType = cursor.getString(3); val category = cursor.getString(4); + val durationMs = cursor.getLong(5) + val duration = if (durationMs > 0) durationMs / 1000 else -1 val idLong = id.toLongOrNull(); val contentUrl = if(idLong != null ) @@ -534,7 +537,7 @@ class StateLibrary { PlatformID("FILE", contentUrl, null, 0, -1), displayName, Thumbnails(arrayOf( Thumbnail(contentUrl, 0) - )), authorObj, contentUrl, -1, contentType, dateObj); + )), authorObj, contentUrl, duration, contentType, dateObj); } private var _instance : StateLibrary? = null; From 961710cc8bd67b8dbb17c1c89567f2f35b79944f Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 3 Dec 2025 15:37:53 +0100 Subject: [PATCH 06/20] Fixed thumbnails acting up and added support for library content thumbnail when casting. --- .../java/com/futo/platformplayer/Utility.kt | 12 ++------ .../server/handlers/HttpContentUriHandler.kt | 19 +++++++------ .../platformplayer/casting/StateCasting.kt | 28 ++++++++++++++++--- .../platformplayer/states/StateLibrary.kt | 23 ++++++++------- 4 files changed, 50 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index b154cb67..5713fac7 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -444,15 +444,9 @@ fun addressScore(addr: InetAddress): Int { fun Enumeration.toList(): List = Collections.list(this) -fun RequestBuilder.withMaxSizePx(maxSizePx: Int = 1920, useCenterCrop: Boolean = false): RequestBuilder { - var builder = this +fun RequestBuilder.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder { + return this .downsample(DownsampleStrategy.AT_MOST) .override(maxSizePx, maxSizePx) - builder = if (useCenterCrop) { - builder.centerCrop() - } else { - builder.fitCenter() - } - - return builder + .centerInside() } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpContentUriHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpContentUriHandler.kt index 6407e552..63745991 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpContentUriHandler.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpContentUriHandler.kt @@ -137,14 +137,7 @@ class HttpContentUriHandler( var size: Long? = null var lastModifiedMillis: Long? = null - val projection = arrayOf( - OpenableColumns.DISPLAY_NAME, - OpenableColumns.SIZE, - MediaStore.MediaColumns.DATE_MODIFIED, - MediaStore.MediaColumns.DATE_ADDED - ) - - resolver.query(uri, projection, null, null, null)?.use { cursor -> + resolver.query(uri, null, null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) if (nameIndex != -1 && !cursor.isNull(nameIndex)) { @@ -177,6 +170,10 @@ class HttpContentUriHandler( } } + if (displayName == null) { + displayName = uri.lastPathSegment + } + if (size == null) { try { resolver.openAssetFileDescriptor(uri, "r")?.use { afd -> @@ -188,7 +185,11 @@ class HttpContentUriHandler( } catch (_: Exception) { } } - return ContentMeta(displayName = displayName, size = size, lastModifiedMillis = lastModifiedMillis) + return ContentMeta( + displayName = displayName, + size = size, + lastModifiedMillis = lastModifiedMillis + ) } private fun parseRange(header: String, totalLength: Long): LongRange? { diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index e531354a..5c38f12f 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -239,9 +239,9 @@ abstract class StateCasting { Logger.i(TAG, "Connect to device ${device.name}") } - fun metadataFromVideo(video: IPlatformVideoDetails): Metadata { + fun metadataFromVideo(video: IPlatformVideoDetails, videoThumbnailOverrideUrl: String? = null): Metadata { return Metadata( - title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail() + title = video.name, thumbnailUrl = videoThumbnailOverrideUrl ?: video.thumbnails.getHQThumbnail() ) } @@ -479,6 +479,16 @@ abstract class StateCasting { val id = UUID.randomUUID(); val videoPath = "/video-${id}" val videoUrl = url + videoPath; + val thumbnailPath = "/thumbnail-${id}" + val thumbnailUrl = url + thumbnailPath; + val thumbnailContentUrl = video.thumbnails.getHQThumbnail() + + if (thumbnailContentUrl != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri()) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } _castServer.addHandlerWithAllowAllOptions( HttpContentUriHandler("GET", videoPath, contentResolver, videoSource.contentUrl.toUri()) @@ -486,7 +496,7 @@ abstract class StateCasting { ).withTag("cast"); Logger.i(TAG, "Casting local video (videoUrl: $videoUrl)."); - ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); + ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null)); return listOf(videoUrl); } @@ -498,6 +508,16 @@ abstract class StateCasting { val id = UUID.randomUUID(); val audioPath = "/audio-${id}" val audioUrl = url + audioPath; + val thumbnailPath = "/thumbnail-${id}" + val thumbnailUrl = url + thumbnailPath; + val thumbnailContentUrl = video.thumbnails.getHQThumbnail() + + if (thumbnailContentUrl != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri()) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } _castServer.addHandlerWithAllowAllOptions( HttpContentUriHandler("GET", audioPath, contentResolver, audioSource.contentUrl.toUri()) @@ -505,7 +525,7 @@ abstract class StateCasting { ).withTag("cast"); Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl)."); - ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); + ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null)); return listOf(audioUrl); } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt index 06f7dfb3..b2d4149b 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt @@ -488,9 +488,10 @@ class StateLibrary { ""; - val albumContentUrl = if(albumId > 0) - ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString() - else null; + val albumArtBase = Uri.parse("content://media/external/audio/albumart") + val albumContentUrl = if (albumId > 0) + ContentUris.withAppendedId(albumArtBase, albumId).toString() + else null val dateObj = if(date > 0) OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC) @@ -625,11 +626,12 @@ class Artist { val numTracks = cursor.getInt(2); val numAlbums = cursor.getInt(3); - val idLong = id.toLongOrNull(); - val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null; + val idLong = id.toLongOrNull() + val uri = if (idLong != null) + ContentUris.withAppendedId(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, idLong) + else null - return Artist(artist, numTracks, numAlbums, null, id, uri?.toString()); - } + return Artist(artist, numTracks, numAlbums, null, id, uri?.toString()) } fun getArtist(id: Long): Artist? { val resolver = StateApp.instance.contextOrNull?.contentResolver; @@ -733,9 +735,10 @@ class Album { val numTracks = cursor.getInt(2); val artist = cursor.getString(3); - val idLong = id.toLongOrNull(); - val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null; - return Album(album, numTracks, artist, id, uri?.toString()); + val idLong = id.toLongOrNull() + val albumArtBase = Uri.parse("content://media/external/audio/albumart") + val uri = if (idLong != null) ContentUris.withAppendedId(albumArtBase, idLong) else null + return Album(album, numTracks, artist, id, uri?.toString()) } fun getAlbumTracks(albumId: Long): List { From 300466f72223d88bf8c54aa04bc96e20e6a66467 Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 3 Dec 2025 16:33:51 +0100 Subject: [PATCH 07/20] Update dialogs should nicely be hidden when interacting with notifications. --- app/src/main/java/com/futo/platformplayer/UIDialogs.kt | 10 ++++++---- .../com/futo/platformplayer/UpdateActionReceiver.kt | 6 ++++++ .../com/futo/platformplayer/UpdateDownloadService.kt | 8 ++++++-- .../futo/platformplayer/dialogs/AutoUpdateDialog.kt | 4 ++++ .../fragment/mainactivity/main/ChannelFragment.kt | 2 +- .../platformplayer/views/lists/VideoListEditorView.kt | 2 +- 6 files changed, 24 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index 6090aa56..6c1e134d 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -370,17 +370,19 @@ class UIDialogs { } - fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) { + fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null): AlertDialog { val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY) val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT) - showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction) + return showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction).apply { + setOnDismissListener { dismissAction?.invoke() } + } } - fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) { + fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null): AlertDialog { val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY) val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT) val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE) - showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction) + return showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction) } fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) { diff --git a/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt b/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt index 42f452f0..1789cc91 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt @@ -6,6 +6,7 @@ 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 @@ -21,6 +22,8 @@ class UpdateActionReceiver : BroadcastReceiver() { } private fun handleUpdateYes(context: Context, intent: Intent) { + AutoUpdateDialog.currentDialog?.dismiss() + val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0) if (version == 0) { return @@ -49,10 +52,12 @@ class UpdateActionReceiver : BroadcastReceiver() { } 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() @@ -86,5 +91,6 @@ class UpdateActionReceiver : BroadcastReceiver() { UpdateNotificationManager.cancelAll(context) UpdateInstaller.startInstall(context, apkFile) + UpdateDownloadService.updateDownloadedDialog?.dismiss() } } diff --git a/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt index fe01051a..bc860479 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer +import android.app.Dialog import android.app.Service import android.content.Intent import android.os.IBinder @@ -21,6 +22,8 @@ class UpdateDownloadService : Service() { private const val MAX_RETRIES = 5 private const val INITIAL_BACKOFF_MS = 5_000L private const val BUFFER_SIZE = 8 * 1024 + + var updateDownloadedDialog: Dialog? = null } private val job = SupervisorJob() @@ -216,12 +219,13 @@ class UpdateDownloadService : Service() { StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { StateApp.withContext { ctx -> try { - UIDialogs.showConfirmationDialog(ctx, "Update downloaded, press confirm to install", { + updateDownloadedDialog = UIDialogs.showConfirmationDialog(ctx, "Update downloaded, press confirm to install", { UpdateNotificationManager.cancelAll(ctx) UpdateInstaller.startInstall(ctx, apkFile) - }, {}) + }, dismissAction = { updateDownloadedDialog = null }) } catch (t: Throwable) { Logger.w(TAG, "Failed to show in-app update downloaded dialog", t) + updateDownloadedDialog = null } } } diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt index fbca0f6b..9cfb840d 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt @@ -36,6 +36,8 @@ import java.io.InputStream class AutoUpdateDialog(context: Context?) : AlertDialog(context) { companion object { private val TAG = "AutoUpdateDialog"; + + var currentDialog: AutoUpdateDialog? = null } private lateinit var _buttonNever: Button; @@ -94,11 +96,13 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) { } }; + currentDialog = this } override fun dismiss() { super.dismiss() InstallReceiver.onReceiveResult.clear(); + currentDialog = null Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.") } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt index 541d7c7c..c95238aa 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt @@ -409,7 +409,7 @@ class ChannelFragment : MainFragment() { _fragment.topBar?.onShown(channel) val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) { - UIDialogs.showConfirmationDialog(context, + val dialog = UIDialogs.showConfirmationDialog(context, context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist) .replace("{channelName}", channel.name), { diff --git a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt index 67be4058..7bc7dffe 100644 --- a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt @@ -77,7 +77,7 @@ class VideoListEditorView : FrameLayout { executeDelete() }, cancelAction = { - }, doNotAskAgainAction = { + }, dismissAction = {}, doNotAskAgainAction = { Settings.instance.other.playlistDeleteConfirmation = false Settings.instance.save() }) From 26461c21c40546817d04860477ba1da0c21019cd Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 3 Dec 2025 09:58:08 -0600 Subject: [PATCH 08/20] move to background on back with video --- .../java/com/futo/platformplayer/activities/MainActivity.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index b9ecf3f4..bc6b0d9c 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -1301,9 +1301,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) { finish(); } else { + //UIDialogs.toast("Grayjay continues in background because of an open video.") + moveTaskToBack(false); + /* UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", { finish(); }) + */ } } } From 99eee4f6ee0ec41fa379d5e48b5de8bbec560897 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 3 Dec 2025 10:27:40 -0600 Subject: [PATCH 09/20] Disable misbehaving thumbnail rendering --- app/src/main/java/com/futo/platformplayer/Utility.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index 5713fac7..c41be6d0 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -445,8 +445,8 @@ fun addressScore(addr: InetAddress): Int { fun Enumeration.toList(): List = Collections.list(this) fun RequestBuilder.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder { - return this - .downsample(DownsampleStrategy.AT_MOST) - .override(maxSizePx, maxSizePx) - .centerInside() + return this; + //.downsample(DownsampleStrategy.AT_MOST) + //.override(maxSizePx, maxSizePx) + //.centerInside() } \ No newline at end of file From 7f77c3929645c8336896b2ac062307576b6d2b7e Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 3 Dec 2025 17:33:41 +0100 Subject: [PATCH 10/20] Made notifications for update silent. --- .../platformplayer/UpdateNotificationManager.kt | 13 +++++++++++-- .../main/java/com/futo/platformplayer/Utility.kt | 5 ++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt b/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt index aafeeec4..b7b75951 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt @@ -36,12 +36,17 @@ object UpdateNotificationManager { fun ensureChannel(context: Context) { val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (manager.getNotificationChannel(CHANNEL_ID) == null) { - val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT) - channel.description = CHANNEL_DESCRIPTION + val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply { + description = CHANNEL_DESCRIPTION + enableVibration(false) + enableLights(false) + setSound(null, null) + } manager.createNotificationChannel(channel) } } + fun showUpdateAvailableNotification(context: Context, version: Int) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { return @@ -70,6 +75,7 @@ object UpdateNotificationManager { .setContentText("A new version ($version) is available.") .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) + .setSilent(true) .addAction(0, "Download", yesPendingIntent) .addAction(0, "Not now", noPendingIntent) .addAction(0, "Never", neverPendingIntent) @@ -97,6 +103,7 @@ object UpdateNotificationManager { .setContentText("Downloading version $version") .setPriority(NotificationCompat.PRIORITY_LOW) .setOngoing(true) + .setSilent(true) .addAction(0, "Cancel", cancelPendingIntent) if (indeterminate) { @@ -141,6 +148,7 @@ object UpdateNotificationManager { .setContentText("Tap to install version $version.") .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) + .setSilent(true) .addAction(0, "Install", installPendingIntent) NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build()) @@ -159,6 +167,7 @@ object UpdateNotificationManager { .setContentText(error?.message ?: "Unknown error") .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) + .setSilent(true) NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build()) } diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index 5713fac7..932600a2 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -445,8 +445,7 @@ fun addressScore(addr: InetAddress): Int { fun Enumeration.toList(): List = Collections.list(this) fun RequestBuilder.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder { - return this + return this/* .downsample(DownsampleStrategy.AT_MOST) - .override(maxSizePx, maxSizePx) - .centerInside() + .override(maxSizePx, maxSizePx)*/ } \ No newline at end of file From f12e4390f34ecc9940ef5b2689a8b8caacfec44c Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 3 Dec 2025 17:48:47 +0100 Subject: [PATCH 11/20] Changed the order of buttons. --- .../java/com/futo/platformplayer/UpdateNotificationManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt b/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt index b7b75951..279d0306 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt @@ -76,9 +76,9 @@ object UpdateNotificationManager { .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) .setSilent(true) - .addAction(0, "Download", yesPendingIntent) - .addAction(0, "Not now", noPendingIntent) .addAction(0, "Never", neverPendingIntent) + .addAction(0, "Not now", noPendingIntent) + .addAction(0, "Download", yesPendingIntent) NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build()) } From 0a02169782b42a32060e9adc60704ecef7bb6a14 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 3 Dec 2025 11:10:22 -0600 Subject: [PATCH 12/20] Fix PiP for back button --- .../com/futo/platformplayer/activities/MainActivity.kt | 10 +++++++++- .../fragment/mainactivity/main/VideoDetailFragment.kt | 5 +++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index bc6b0d9c..29eef9c9 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -1302,7 +1302,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { finish(); } else { //UIDialogs.toast("Grayjay continues in background because of an open video.") - moveTaskToBack(false); + if(Settings.instance.playback.isBackgroundPictureInPicture()) { + try { + _fragVideoDetail._viewDetail?.startPictureInPicture(); + _fragVideoDetail?.forcePictureInPicture(); + } catch (ex: Throwable) { + } //Fail silently + } + else + moveTaskToBack(false); /* UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", { finish(); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index 26a2577b..d9274e7a 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -50,7 +50,7 @@ class VideoDetailFragment() : MainFragment() { private var _isActive: Boolean = false; - private var _viewDetail : VideoDetailView? = null; + var _viewDetail : VideoDetailView? = null; private var _view : SingleViewTouchableMotionLayout? = null; var isFullscreen : Boolean = false; @@ -450,7 +450,8 @@ class VideoDetailFragment() : MainFragment() { if (viewDetail.shouldEnterPictureInPicture) { _leavingPiP = false } - if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) { + val shouldPiP = Settings.instance.playback.isBackgroundPictureInPicture() + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && shouldPiP && !viewDetail.isAudioOnlyUserAction) { val params = _viewDetail?.getPictureInPictureParams(); if(params != null) { Logger.i(TAG, "enterPictureInPictureMode") From 042ced81efcb226ae53cad1f96671f218be6b118 Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 3 Dec 2025 18:18:43 +0100 Subject: [PATCH 13/20] Fix for update when app is fully killed. --- app/src/main/AndroidManifest.xml | 7 +++ .../platformplayer/UpdateActionReceiver.kt | 39 ++------------- .../UpdateNotificationManager.kt | 16 ++----- .../activities/InstallUpdateActivity.kt | 47 +++++++++++++++++++ app/src/main/res/values/themes.xml | 10 ++++ 5 files changed, 72 insertions(+), 47 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/activities/InstallUpdateActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9cafddaa..5c5fb27d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -261,5 +261,12 @@ android:name=".UpdateActionReceiver" android:exported="false" /> + + diff --git a/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt b/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt index 1789cc91..3050a154 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt @@ -17,7 +17,6 @@ class UpdateActionReceiver : BroadcastReceiver() { UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context) UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context) UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent) - UpdateNotificationManager.ACTION_INSTALL_NOW -> handleInstallNow(context, intent) } } @@ -31,24 +30,10 @@ class UpdateActionReceiver : BroadcastReceiver() { NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE) - if (Settings.instance.autoUpdate.backgroundDownload == 1) { - val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply { - putExtra(UpdateDownloadService.EXTRA_VERSION, version) - } - ContextCompat.startForegroundService(context, serviceIntent) - } else { - if (StateApp.instance.isMainActive) { - StateApp.withContext { ctx -> - UIDialogs.showUpdateAvailableDialog(ctx, version, false) - } - } else { - val startIntent = Intent(context, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) - putExtra("SHOW_UPDATE_DIALOG_VERSION", version) - } - context.startActivity(startIntent) - } + val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply { + putExtra(UpdateDownloadService.EXTRA_VERSION, version) } + ContextCompat.startForegroundService(context, serviceIntent) } private fun handleUpdateNo(context: Context) { @@ -75,22 +60,4 @@ class UpdateActionReceiver : BroadcastReceiver() { NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING) } - - private fun handleInstallNow(context: Context, intent: Intent) { - val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0) - val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH) - - if (version == 0 || apkPath.isNullOrEmpty()) { - return - } - - val apkFile = File(apkPath) - if (!apkFile.exists()) { - return - } - - UpdateNotificationManager.cancelAll(context) - UpdateInstaller.startInstall(context, apkFile) - UpdateDownloadService.updateDownloadedDialog?.dismiss() - } } diff --git a/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt b/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt index 279d0306..09a39ee9 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt @@ -4,6 +4,7 @@ import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.app.PendingIntent.FLAG_MUTABLE import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.app.PendingIntent.getBroadcast @@ -13,6 +14,7 @@ import android.content.pm.PackageManager import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import com.futo.platformplayer.activities.InstallUpdateActivity import java.io.File object UpdateNotificationManager { @@ -25,6 +27,7 @@ object UpdateNotificationManager { 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 const val EXTRA_VERSION = "version" const val EXTRA_APK_PATH = "apk_path" @@ -130,17 +133,8 @@ object UpdateNotificationManager { } ensureChannel(context) - val installIntent = Intent(context, UpdateActionReceiver::class.java).apply { - action = ACTION_INSTALL_NOW - putExtra(EXTRA_VERSION, version) - putExtra(EXTRA_APK_PATH, apkFile.absolutePath) - } - val installPendingIntent = getBroadcast( - context, - 4, - installIntent, - FLAG_MUTABLE or FLAG_UPDATE_CURRENT - ) + val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath) + val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val builder = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.foreground) diff --git a/app/src/main/java/com/futo/platformplayer/activities/InstallUpdateActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/InstallUpdateActivity.kt new file mode 100644 index 00000000..24e5299b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/InstallUpdateActivity.kt @@ -0,0 +1,47 @@ +package com.futo.platformplayer.activities + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.UpdateInstaller +import com.futo.platformplayer.UpdateNotificationManager +import com.futo.platformplayer.logging.Logger +import java.io.File + +class InstallUpdateActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0) + val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH) + + if (version == 0 || apkPath.isNullOrEmpty()) { + Logger.w("InstallUpdateActivity", "Missing version or apkPath") + finish() + return + } + + val apkFile = File(apkPath) + if (!apkFile.exists()) { + Logger.w("InstallUpdateActivity", "APK file does not exist: $apkPath") + UIDialogs.Companion.toast(this, "Update file missing") + finish() + return + } + + UpdateInstaller.startInstall(this, apkFile) + finish() + } + + companion object { + fun createIntent(context: Context, version: Int, apkPath: String): Intent = + Intent(context, InstallUpdateActivity::class.java).apply { + putExtra(UpdateNotificationManager.EXTRA_VERSION, version) + putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkPath) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } +} diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index fe77ab0b..63173930 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -116,4 +116,14 @@ @font/inter_regular + + \ No newline at end of file From 7ed1e8a28b586fa92ed0b6099543941c05489273 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 3 Dec 2025 11:44:27 -0600 Subject: [PATCH 14/20] NEw install dialog, incognito dont show fix, crash fix old android search library --- .../platformplayer/UpdateDownloadService.kt | 15 +++++++++++---- .../bottombar/MenuBottomBarFragment.kt | 17 +++++++++-------- .../topbar/GeneralTopBarFragment.kt | 7 ++++++- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt index bc860479..485a9ea8 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt @@ -4,6 +4,7 @@ import android.app.Dialog import android.app.Service import android.content.Intent import android.os.IBinder +import com.futo.platformplayer.UIDialogs.ActionStyle import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateUpdate @@ -219,10 +220,16 @@ class UpdateDownloadService : Service() { StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { StateApp.withContext { ctx -> try { - updateDownloadedDialog = UIDialogs.showConfirmationDialog(ctx, "Update downloaded, press confirm to install", { - UpdateNotificationManager.cancelAll(ctx) - UpdateInstaller.startInstall(ctx, apkFile) - }, dismissAction = { updateDownloadedDialog = null }) + updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground, + "Update downloaded", + "Would you like to install it now?", null, 0, + UIDialogs.Action("Cancel", { + updateDownloadedDialog = null + }, ActionStyle.NONE, true), + UIDialogs.Action("Install", { + UpdateNotificationManager.cancelAll(ctx) + UpdateInstaller.startInstall(ctx, apkFile) + }, ActionStyle.PRIMARY, true)); } catch (t: Throwable) { Logger.w(TAG, "Failed to show in-app update downloaded dialog", t) updateDownloadedDialog = null diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt index a6dc02ae..3e176ece 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt @@ -155,15 +155,16 @@ class MenuBottomBarFragment : MainActivityFragment() { StateApp.instance.setPrivacyMode(true); UIDialogs.appToast("Privacy mode enabled"); - UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode", - "All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0, - UIDialogs.Action("Don't show again", { - Settings.instance.other.showPrivacyModeDialog = false; - Settings.instance.save(); - }, UIDialogs.ActionStyle.NONE), - UIDialogs.Action("Understood", { + if(Settings.instance.other.showPrivacyModeDialog) + UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode", + "All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0, + UIDialogs.Action("Don't show again", { + Settings.instance.other.showPrivacyModeDialog = false; + Settings.instance.save(); + }, UIDialogs.ActionStyle.NONE), + UIDialogs.Action("Understood", { - }, UIDialogs.ActionStyle.PRIMARY)); + }, UIDialogs.ActionStyle.PRIMARY)); } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/GeneralTopBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/GeneralTopBarFragment.kt index 43f9dc26..a496c668 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/GeneralTopBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/GeneralTopBarFragment.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.fragment.mainactivity.topbar +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -49,7 +50,11 @@ class GeneralTopBarFragment : TopFragment() { } else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) { navigate(SuggestionsFragmentData("", SearchType.PLAYLIST)); } else if (currentMain is LibraryFragment) { - navigate(); + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + UIDialogs.toast("Your Android version is too old for Mediastore search", true); + } + else + navigate(); } else { navigate(SuggestionsFragmentData("", SearchType.VIDEO)); } From 86019c80a1208ee4672faddb1a970194f59169bb Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 3 Dec 2025 11:58:00 -0600 Subject: [PATCH 15/20] Fix in-video login flow --- .../platformplayer/fragment/mainactivity/main/LoginFragment.kt | 2 +- .../main/java/com/futo/platformplayer/states/StatePlugins.kt | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LoginFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LoginFragment.kt index 86cb4cc4..9b05816c 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LoginFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LoginFragment.kt @@ -55,7 +55,7 @@ class LoginFragment : MainFragment() { fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) { if(_callback != null) _callback?.invoke(null); _callback = callback; - StateApp.instance.activity?.navigate(config, false); + StateApp.instance.activity?.navigate(config, true); } } diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt index cb853f07..38473023 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt @@ -169,6 +169,9 @@ class StatePlugins { return false; LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) { + + if(it == null) + return@showLogin; try { StatePlugins.instance.setPluginAuth(config.id, it); } catch (e: Throwable) { From 1bb0cdc4055f349e29245c72502d090947480ea3 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 3 Dec 2025 12:49:08 -0600 Subject: [PATCH 16/20] Add exception handling for background updater --- .../com/futo/platformplayer/UpdateInstaller.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt b/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt index 4f45ed0a..72bdfb0d 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt @@ -7,7 +7,9 @@ import android.app.PendingIntent.getBroadcast import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller +import android.graphics.drawable.Animatable import android.provider.Settings +import android.view.View import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.receivers.InstallReceiver import kotlinx.coroutines.Dispatchers @@ -17,6 +19,8 @@ import kotlinx.coroutines.withContext import java.io.File import java.io.InputStream import androidx.core.net.toUri +import com.futo.platformplayer.dialogs.AutoUpdateDialog +import com.futo.platformplayer.states.StateApp object UpdateInstaller { private const val TAG = "UpdateInstaller" @@ -53,8 +57,8 @@ object UpdateInstaller { GlobalScope.launch(Dispatchers.IO) { var inputStream: InputStream? = null var session: PackageInstaller.Session? = null - try { + val packageInstaller: PackageInstaller = context.packageManager.packageInstaller val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) val sessionId = packageInstaller.createSession(params) @@ -72,6 +76,10 @@ object UpdateInstaller { val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT) val statusReceiver = pendingIntent.intentSender + InstallReceiver.onReceiveResult.subscribe(this) { message -> + InstallReceiver.onReceiveResult.clear(); + onReceiveResult(context, message); + }; Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}") session.commit(statusReceiver) } catch (e: Throwable) { @@ -86,4 +94,11 @@ object UpdateInstaller { } } } + + + + private fun onReceiveResult(context: Context, result: String?) { + InstallReceiver.onReceiveResult.remove(this); + UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n" + result); + } } From 035125d0f8ee22f981ab54c325979eb2230d03df Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 3 Dec 2025 18:06:38 -0600 Subject: [PATCH 17/20] Hotfix invalid closed state --- .../java/com/futo/platformplayer/activities/MainActivity.kt | 1 + .../fragment/mainactivity/main/VideoDetailFragment.kt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 29eef9c9..6bee4b73 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -1299,6 +1299,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { navigate(last.first, last.second, false, true); } else { if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) { + Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed"); finish(); } else { //UIDialogs.toast("Grayjay continues in background because of an open video.") diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index d9274e7a..78f43714 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -373,7 +373,7 @@ class VideoDetailFragment() : MainFragment() { } else if (state != State.MAXIMIZED && progress > 0.9) { if (_isInitialMaximize) { - state = State.CLOSED; + //state = State.CLOSED; Causes issues? might no longer be needed _isInitialMaximize = false; } else { From 1667866a35cc0c677efa49328b4591defa8aed63 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 3 Dec 2025 18:08:36 -0600 Subject: [PATCH 18/20] Hotfix invalid closed state --- .../fragment/mainactivity/main/VideoDetailFragment.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index 78f43714..4c693230 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -372,6 +372,9 @@ class VideoDetailFragment() : MainFragment() { onMinimize.emit(); } else if (state != State.MAXIMIZED && progress > 0.9) { + state = State.MAXIMIZED; + onMaximized.emit(); + /* if (_isInitialMaximize) { //state = State.CLOSED; Causes issues? might no longer be needed _isInitialMaximize = false; @@ -380,6 +383,7 @@ class VideoDetailFragment() : MainFragment() { state = State.MAXIMIZED; onMaximized.emit(); } + */ } if (isTransitioning && (progress > 0.6 || progress < 0.4)) { From 09fd4c08818cc93de6d1b3ffa7ca88a0d3c6fd69 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 3 Dec 2025 18:37:06 -0600 Subject: [PATCH 19/20] Fix it asking for background updating when not required --- app/src/main/java/com/futo/platformplayer/Settings.kt | 6 +++--- .../java/com/futo/platformplayer/activities/MainActivity.kt | 4 ++-- .../com/futo/platformplayer/dialogs/AutoUpdateDialog.kt | 2 +- .../main/java/com/futo/platformplayer/states/StateApp.kt | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 6bfedcca..b01e6a94 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -875,9 +875,9 @@ class Settings : FragmentedStorageFileJson() { @DropdownFieldOptionsId(R.array.auto_update_when_array) var check: Int = 0; - @FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1) - @DropdownFieldOptionsId(R.array.background_download) - var backgroundDownload: Int = 0; + @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; @FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2) @DropdownFieldOptionsId(R.array.when_download) diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 6bee4b73..85e4c47d 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -618,8 +618,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply() } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled()) { - requestNotificationPermissions("Grayjay uses notifications to inform you when a new app update is available."); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled() && Settings.instance.autoUpdate.shouldBackgroundDownload) { + requestNotificationPermissions("You have enabled background updating.\n\nGrayjay uses notifications to inform you when a new app update is available."); } val submissionStatus = FragmentedStorage.get("subscriptionSubmissionStatus") diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt index 9cfb840d..e5cbb322 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt @@ -83,7 +83,7 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) { return@setOnClickListener; } - if (Settings.instance.autoUpdate.backgroundDownload == 1) { + if (Settings.instance.autoUpdate.shouldBackgroundDownload) { val ctx = context.applicationContext; val intent = Intent(ctx, UpdateDownloadService::class.java); intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 7ac860e3..ad58ffbb 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -573,7 +573,7 @@ class StateApp { } if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) { - if (Settings.instance.autoUpdate.backgroundDownload == 1) { + if (Settings.instance.autoUpdate.shouldBackgroundDownload) { Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]"); val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) From bda534e4857bad5fd45c4ead40441908777a2db2 Mon Sep 17 00:00:00 2001 From: Koen J Date: Thu, 4 Dec 2025 11:18:00 +0100 Subject: [PATCH 20/20] Various updates to bg update flow: - Throttled progress updates in notifications resolving the notifications not showing under some conditions. - Properly cancel notifications when interacting with in-app dialogs. - Added install failed notification. - Added install success notification. - Added default behavior for tapping on notifications. - Fixed crash in install receiver. --- .../platformplayer/UpdateDownloadService.kt | 28 +++++++-- .../futo/platformplayer/UpdateInstaller.kt | 32 +++++++--- .../UpdateNotificationManager.kt | 61 ++++++++++++++++++- .../activities/InstallUpdateActivity.kt | 4 +- .../dialogs/AutoUpdateDialog.kt | 5 ++ 5 files changed, 117 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt index 485a9ea8..3147ce62 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt @@ -4,6 +4,7 @@ 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 com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp @@ -23,6 +24,7 @@ class UpdateDownloadService : Service() { private const val MAX_RETRIES = 5 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 } @@ -36,6 +38,8 @@ class UpdateDownloadService : Service() { @Volatile private var cancelRequested: Boolean = false + private var lastProgressUpdateElapsedMs: Long = 0L + override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -81,6 +85,16 @@ class UpdateDownloadService : Service() { job.cancel() } + private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) { + val now = SystemClock.elapsedRealtime() + val force = progress == 100 && !indeterminate + + if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) { + lastProgressUpdateElapsedMs = now + UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate) + } + } + private suspend fun downloadApk(version: Int) { val apkFile = StateUpdate.getApkFile(this, version) val partialFile = StateUpdate.getPartialApkFile(this, version) @@ -190,12 +204,18 @@ class UpdateDownloadService : Service() { progress > 100 -> 100 else -> progress } - UpdateNotificationManager.updateDownloadProgress(this, version, safeProgress, false) + throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false) } } else { - UpdateNotificationManager.updateDownloadProgress(this, version, 0, true) + throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true) } } + + if (!cancelRequested && totalBytes > 0L) { + val finalProgress = 100 + throttledUpdateDownloadProgress(version, finalProgress, indeterminate = false) + } + output.flush() } } @@ -223,12 +243,12 @@ class UpdateDownloadService : Service() { updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground, "Update downloaded", "Would you like to install it now?", null, 0, - UIDialogs.Action("Cancel", { + UIDialogs.Action("Not now", { updateDownloadedDialog = null }, ActionStyle.NONE, true), UIDialogs.Action("Install", { UpdateNotificationManager.cancelAll(ctx) - UpdateInstaller.startInstall(ctx, apkFile) + UpdateInstaller.startInstall(ctx, version, apkFile) }, ActionStyle.PRIMARY, true)); } catch (t: Throwable) { Logger.w(TAG, "Failed to show in-app update downloaded dialog", t) diff --git a/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt b/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt index 72bdfb0d..b81d5096 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt @@ -26,15 +26,17 @@ object UpdateInstaller { private const val TAG = "UpdateInstaller" @SuppressLint("RequestInstallPackagesPolicy") - fun startInstall(context: Context, apkFile: File) { + fun startInstall(context: Context, version: Int, apkFile: File) { if (!apkFile.exists()) { Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}") UIDialogs.toast(context, "Update file missing") + UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.") return } if (BuildConfig.IS_PLAYSTORE_BUILD) { UIDialogs.toast(context, "Updates are managed by the Play Store") + UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.") return } @@ -42,6 +44,7 @@ object UpdateInstaller { val pm = context.packageManager if (!pm.canRequestPackageInstalls()) { UIDialogs.toast(context, "Allow this app to install updates, then try again") + UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Install update permission was missing.") val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { data = "package:${context.packageName}".toUri() @@ -72,13 +75,16 @@ object UpdateInstaller { session.fsync(sessionStream) } - val intent = Intent(context, InstallReceiver::class.java) + val intent = Intent(context, InstallReceiver::class.java).apply { + putExtra(UpdateNotificationManager.EXTRA_VERSION, version) + putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkFile.absolutePath) + } val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT) val statusReceiver = pendingIntent.intentSender InstallReceiver.onReceiveResult.subscribe(this) { message -> InstallReceiver.onReceiveResult.clear(); - onReceiveResult(context, message); + onReceiveResult(context, version, apkFile, message); }; Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}") session.commit(statusReceiver) @@ -88,6 +94,8 @@ object UpdateInstaller { withContext(Dispatchers.Main) { UIDialogs.toast(context, "Failed to install update: ${e.message}") } + + UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message) } finally { session?.close() inputStream?.close() @@ -95,10 +103,20 @@ object UpdateInstaller { } } + private fun onReceiveResult(context: Context, version: Int, apkFile: File, result: String?) { + try { + InstallReceiver.onReceiveResult.remove(this) - - private fun onReceiveResult(context: Context, result: String?) { - InstallReceiver.onReceiveResult.remove(this); - UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n" + result); + if (result.isNullOrEmpty()) { + Logger.i(TAG, "Update install finished successfully") + UpdateNotificationManager.showInstallSucceededNotification(context, version) + } else { + Logger.w(TAG, "Update install failed: $result") + UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result) + UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result") + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to handle install result", e) + } } } diff --git a/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt b/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt index 09a39ee9..b424dcd9 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt @@ -35,6 +35,8 @@ object UpdateNotificationManager { 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 + const val NOTIF_ID_INSTALL_SUCCEEDED = 2005 fun ensureChannel(context: Context) { val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -49,6 +51,38 @@ object UpdateNotificationManager { } } + fun showInstallSucceededNotification(context: Context, version: Int) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + return + } + + ensureChannel(context) + + val launchIntent = context.packageManager + .getLaunchIntentForPackage(context.packageName) + ?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) + } + + val launchPendingIntent = launchIntent?.let { + PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.foreground) + .setContentTitle("Update installed") + .setContentText("Version $version installed. Tap to open.") + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setSilent(true) + + if (launchPendingIntent != null) { + builder.setContentIntent(launchPendingIntent) + builder.addAction(0, "Open app", launchPendingIntent) + } + + 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) { @@ -78,6 +112,7 @@ object UpdateNotificationManager { .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) @@ -104,7 +139,7 @@ object UpdateNotificationManager { .setSmallIcon(R.drawable.foreground) .setContentTitle("Downloading update") .setContentText("Downloading version $version") - .setPriority(NotificationCompat.PRIORITY_LOW) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setOngoing(true) .setSilent(true) .addAction(0, "Cancel", cancelPendingIntent) @@ -141,6 +176,7 @@ object UpdateNotificationManager { .setContentTitle("Update downloaded") .setContentText("Tap to install version $version.") .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(installPendingIntent) .setAutoCancel(true) .setSilent(true) .addAction(0, "Install", installPendingIntent) @@ -166,9 +202,32 @@ object UpdateNotificationManager { NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build()) } + fun showInstallFailedNotification(context: Context, version: Int, apkFile: File, error: String?) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) + return + + ensureChannel(context) + + val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath) + val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.foreground) + .setContentTitle("Failed to install update") + .setContentText(if (error != null && error.isNotBlank()) "$error Tap to try again." else "Tap to try again.") + .setAutoCancel(true) + .setSilent(true) + .setContentIntent(installPendingIntent) + .addAction(0, "Install again", installPendingIntent) + + NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_FAILED, builder.build()) + } + 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) + } } diff --git a/app/src/main/java/com/futo/platformplayer/activities/InstallUpdateActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/InstallUpdateActivity.kt index 24e5299b..48d600e5 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/InstallUpdateActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/InstallUpdateActivity.kt @@ -15,6 +15,8 @@ class InstallUpdateActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + UpdateNotificationManager.cancelAll(this) + val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0) val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH) @@ -32,7 +34,7 @@ class InstallUpdateActivity : AppCompatActivity() { return } - UpdateInstaller.startInstall(this, apkFile) + UpdateInstaller.startInstall(this, version, apkFile) finish() } diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt index e5cbb322..ccee6082 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt @@ -21,6 +21,7 @@ import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UpdateDownloadService +import com.futo.platformplayer.UpdateNotificationManager import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.copyToOutputStream import com.futo.platformplayer.logging.Logger @@ -64,12 +65,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) { _buttonShowChangelog = findViewById(R.id.button_show_changelog); _buttonNever.setOnClickListener { + UpdateNotificationManager.cancelAll(context) Settings.instance.autoUpdate.check = 1; Settings.instance.save(); dismiss(); }; _buttonClose.setOnClickListener { + UpdateNotificationManager.cancelAll(context) dismiss(); }; @@ -79,6 +82,8 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) { }; _buttonUpdate.setOnClickListener { + UpdateNotificationManager.cancelAll(context) + if (_updating) { return@setOnClickListener; }