From d5991c751d1518273c44f6627b8287fb01cac4a2 Mon Sep 17 00:00:00 2001 From: darkflame91 Date: Tue, 14 Jul 2026 21:54:03 +0530 Subject: [PATCH 1/6] fix(android): correct image based subtitle rendering --- .../features/player/PlayerEngine.android.kt | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 85fe5fdd..4ebfcb3e 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -7,6 +7,7 @@ import android.text.SpannableString import android.net.Uri import android.util.Log import android.util.TypedValue +import android.graphics.RectF import android.graphics.Typeface import android.os.Build import android.view.ViewGroup.LayoutParams.MATCH_PARENT @@ -224,6 +225,7 @@ private fun ExoPlayerSurface( var selectedExternalSubtitleMimeType by remember(playerSourceKey) { mutableStateOf(null) } val latestSubtitleDelayMs = rememberUpdatedState(subtitleDelayMs) val latestExternalSubtitleMimeType = rememberUpdatedState(selectedExternalSubtitleMimeType) + var playerViewRef by remember { mutableStateOf(null) } var decoderPriorityOverride by remember(playerSourceKey) { mutableStateOf(null) } var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf(null) } val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority @@ -309,6 +311,7 @@ private fun ExoPlayerSurface( shouldNormalizeCuePositionProvider = { latestExternalSubtitleMimeType.value == MimeTypes.TEXT_VTT }, + videoBoundsFractionProvider = { playerViewRef?.videoBoundsFraction() }, ) .setExtensionRendererMode(effectiveDecoderPriority) .setEnableDecoderFallback(true) @@ -397,7 +400,6 @@ private fun ExoPlayerSurface( val pendingSubtitleTrackIndex = remember { mutableListOf() } val pendingAudioTrackSelection = remember { mutableListOf() } - var playerViewRef by remember { mutableStateOf(null) } var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) } var subtitleSelectionJob by remember { mutableStateOf(null) } val isInPip = rememberIsInPictureInPicture() @@ -1711,11 +1713,36 @@ private fun ExoPlayer.logCurrentTracks(context: String) { Log.d(TAG, "--- end logCurrentTracks ---") } +@androidx.annotation.OptIn(UnstableApi::class) +private fun PlayerView.videoBoundsFraction(): RectF? { + val subtitleView = this.subtitleView ?: return null + val contentFrame = getTag(androidx.media3.ui.R.id.exo_content_frame) as? AspectRatioFrameLayout + ?: findViewById(androidx.media3.ui.R.id.exo_content_frame) + ?.also { setTag(androidx.media3.ui.R.id.exo_content_frame, it) } + ?: return null + val viewWidth = subtitleView.width.toFloat() + val viewHeight = subtitleView.height.toFloat() + val frameWidth = contentFrame.width.toFloat() + val frameHeight = contentFrame.height.toFloat() + if (viewWidth <= 0f || viewHeight <= 0f || frameWidth <= 0f || frameHeight <= 0f) { + return null + } + val left = contentFrame.x / viewWidth + val top = contentFrame.y / viewHeight + return RectF( + left, + top, + left + frameWidth / viewWidth, + top + frameHeight / viewHeight, + ) +} + @androidx.annotation.OptIn(UnstableApi::class) private class SubtitleOffsetRenderersFactory( context: Context, private val subtitleDelayUsProvider: () -> Long, private val shouldNormalizeCuePositionProvider: () -> Boolean, + private val videoBoundsFractionProvider: () -> RectF?, ) : DefaultRenderersFactory(context) { override fun buildTextRenderers( context: Context, @@ -1727,6 +1754,7 @@ private class SubtitleOffsetRenderersFactory( val normalizingOutput = CueNormalizingTextOutput( delegate = output, shouldNormalizeCuePositionProvider = shouldNormalizeCuePositionProvider, + videoBoundsFractionProvider = videoBoundsFractionProvider, ) val startIndex = out.size super.buildTextRenderers(context, normalizingOutput, outputLooper, extensionRendererMode, out) @@ -1742,6 +1770,7 @@ private class SubtitleOffsetRenderersFactory( private class CueNormalizingTextOutput( private val delegate: TextOutput, private val shouldNormalizeCuePositionProvider: () -> Boolean, + private val videoBoundsFractionProvider: () -> RectF?, ) : TextOutput { override fun onCues(cueGroup: CueGroup) { val processed = cueGroup.cues.map(::processCue) @@ -1758,9 +1787,36 @@ private class CueNormalizingTextOutput( if (shouldNormalizeCuePositionProvider()) { processed = normalizeCuePosition(processed) } + if (processed.bitmap != null) { + val bounds = videoBoundsFractionProvider() + if (bounds != null && bounds.width() > 0f && bounds.height() > 0f) { + val isIdentity = bounds.left == 0f && bounds.top == 0f + && bounds.width() == 1f && bounds.height() == 1f + if (!isIdentity) { + processed = remapBitmapCueToVideoBounds(processed, bounds) + } + } + } return processed } + private fun remapBitmapCueToVideoBounds(cue: Cue, bounds: RectF): Cue { + val builder = cue.buildUpon() + if (cue.position != Cue.DIMEN_UNSET) { + builder.setPosition(bounds.left + cue.position * bounds.width()) + } + if (cue.size != Cue.DIMEN_UNSET) { + builder.setSize(cue.size * bounds.width()) + } + if (cue.lineType == Cue.LINE_TYPE_FRACTION && cue.line != Cue.DIMEN_UNSET) { + builder.setLine(bounds.top + cue.line * bounds.height(), Cue.LINE_TYPE_FRACTION) + } + if (cue.bitmapHeight != Cue.DIMEN_UNSET) { + builder.setBitmapHeight(cue.bitmapHeight * bounds.height()) + } + return builder.build() + } + private fun normalizeCuePosition(cue: Cue): Cue { if (cue.bitmap != null || cue.verticalType != Cue.TYPE_UNSET || cue.line == Cue.DIMEN_UNSET) { return cue From ec63003fabb4a74e35bb68934d3c238f7c2daa5f Mon Sep 17 00:00:00 2001 From: darkflame91 Date: Sat, 18 Jul 2026 20:03:06 +0530 Subject: [PATCH 2/6] fix(android): correct fill/zoom image based subtitle rendering --- .../features/player/PlayerEngine.android.kt | 44 ++++++++++++++----- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 4ebfcb3e..319c4eae 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -39,6 +39,7 @@ import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.VideoSize import androidx.media3.common.text.Cue import androidx.media3.common.text.CueGroup import androidx.media3.common.util.UnstableApi @@ -226,6 +227,8 @@ private fun ExoPlayerSurface( val latestSubtitleDelayMs = rememberUpdatedState(subtitleDelayMs) val latestExternalSubtitleMimeType = rememberUpdatedState(selectedExternalSubtitleMimeType) var playerViewRef by remember { mutableStateOf(null) } + var videoAspectRatio by remember(playerSourceKey) { mutableStateOf(0f) } + val latestVideoAspectRatio = rememberUpdatedState(videoAspectRatio) var decoderPriorityOverride by remember(playerSourceKey) { mutableStateOf(null) } var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf(null) } val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority @@ -311,7 +314,9 @@ private fun ExoPlayerSurface( shouldNormalizeCuePositionProvider = { latestExternalSubtitleMimeType.value == MimeTypes.TEXT_VTT }, - videoBoundsFractionProvider = { playerViewRef?.videoBoundsFraction() }, + videoBoundsFractionProvider = { + playerViewRef?.videoBoundsFraction(latestVideoAspectRatio.value) + }, ) .setExtensionRendererMode(effectiveDecoderPriority) .setEnableDecoderFallback(true) @@ -514,10 +519,6 @@ private fun ExoPlayerSurface( dispatchExoPlayerSnapshot() } - override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) { - latestOnSnapshot.value(exoPlayer.snapshot()) - } - override fun onIsPlayingChanged(isPlaying: Boolean) { syncPlayerViewKeepScreenOn() dispatchExoPlayerSnapshot() @@ -527,6 +528,13 @@ private fun ExoPlayerSurface( dispatchExoPlayerSnapshot() } + override fun onVideoSizeChanged(videoSize: VideoSize) { + latestOnSnapshot.value(exoPlayer.snapshot()) + if (videoSize.width > 0 && videoSize.height > 0) { + videoAspectRatio = videoSize.width.toFloat() / videoSize.height.toFloat() + } + } + override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { Log.d(TAG, "onTracksChanged: ${tracks.groups.size} groups total") exoPlayer.logCurrentTracks("onTracksChanged") @@ -1714,19 +1722,33 @@ private fun ExoPlayer.logCurrentTracks(context: String) { } @androidx.annotation.OptIn(UnstableApi::class) -private fun PlayerView.videoBoundsFraction(): RectF? { +private fun PlayerView.videoBoundsFraction(aspectRatio: Float): RectF? { val subtitleView = this.subtitleView ?: return null + val viewWidth = subtitleView.width.toFloat() + val viewHeight = subtitleView.height.toFloat() + if (viewWidth <= 0f || viewHeight <= 0f) return null + + if (aspectRatio > 0f) { + val parentRatio = viewWidth / viewHeight + return if (parentRatio > aspectRatio) { + val fitW = viewHeight * aspectRatio + val leftPx = (viewWidth - fitW) / 2f + RectF(leftPx / viewWidth, 0f, (leftPx + fitW) / viewWidth, 1f) + } else { + val fitH = viewWidth / aspectRatio + val topPx = (viewHeight - fitH) / 2f + RectF(0f, topPx / viewHeight, 1f, (topPx + fitH) / viewHeight) + } + } + val contentFrame = getTag(androidx.media3.ui.R.id.exo_content_frame) as? AspectRatioFrameLayout ?: findViewById(androidx.media3.ui.R.id.exo_content_frame) ?.also { setTag(androidx.media3.ui.R.id.exo_content_frame, it) } ?: return null - val viewWidth = subtitleView.width.toFloat() - val viewHeight = subtitleView.height.toFloat() val frameWidth = contentFrame.width.toFloat() val frameHeight = contentFrame.height.toFloat() - if (viewWidth <= 0f || viewHeight <= 0f || frameWidth <= 0f || frameHeight <= 0f) { - return null - } + if (frameWidth <= 0f || frameHeight <= 0f) return null + if (frameWidth > viewWidth || frameHeight > viewHeight) return null val left = contentFrame.x / viewWidth val top = contentFrame.y / viewHeight return RectF( From 6f4c69f536be8598f4db8f6339da87a77e53ec8e Mon Sep 17 00:00:00 2001 From: Sobhan Date: Sun, 26 Jul 2026 04:46:00 +0530 Subject: [PATCH 3/6] fix(ui): clip CardDepthEffect sheen overlay to shape path --- .../com/nuvio/app/core/ui/CardDepthEffect.kt | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthEffect.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthEffect.kt index ed9581ee..3578cc47 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthEffect.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthEffect.kt @@ -12,6 +12,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp +import androidx.compose.ui.graphics.drawscope.clipPath + @Composable fun rememberCardDepthStyleUiState(): CardDepthStyleUiState { CardDepthStyleRepository.ensureLoaded() @@ -77,20 +79,31 @@ fun Modifier.cardDepthVisual( drawContent() val sheenHeight = size.height * 0.22f if (sheenHeight > 0f) { - drawRect( - brush = Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = sheen), - Color.Transparent, + val outline = shape.createOutline(size, layoutDirection, this) + val shapePath = outline.toPath() + clipPath(shapePath) { + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = sheen), + Color.Transparent, + ), + startY = 0f, + endY = sheenHeight, ), - startY = 0f, - endY = sheenHeight, - ), - size = Size(size.width, sheenHeight), - ) + size = Size(size.width, sheenHeight), + ) + } } } } else { withEdge } } + +private fun androidx.compose.ui.graphics.Outline.toPath(): androidx.compose.ui.graphics.Path = when (this) { + is androidx.compose.ui.graphics.Outline.Rectangle -> androidx.compose.ui.graphics.Path().apply { addRect(rect) } + is androidx.compose.ui.graphics.Outline.Rounded -> androidx.compose.ui.graphics.Path().apply { addRoundRect(roundRect) } + is androidx.compose.ui.graphics.Outline.Generic -> path +} + From 8937dc7b6bed6ec3bb12f9a737c4a12bdec67607 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:19:22 +0530 Subject: [PATCH 4/6] Update Play account and foreground service compliance --- androidApp/src/playstore/AndroidManifest.xml | 18 ++++++++++++++++++ .../app/core/build/AppFeaturePolicy.android.kt | 1 + .../PlayerNowPlayingController.android.kt | 8 +++++++- .../app/core/build/AppFeaturePolicy.android.kt | 3 ++- .../composeResources/values/strings.xml | 2 ++ .../nuvio/app/core/build/AppFeaturePolicy.kt | 1 + .../app/features/settings/SettingsRootPage.kt | 15 +++++++++++++++ .../app/core/build/AppFeaturePolicy.desktop.kt | 1 + .../app/core/build/AppFeaturePolicy.ios.kt | 1 + .../app/core/build/AppFeaturePolicy.ios.kt | 1 + 10 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 androidApp/src/playstore/AndroidManifest.xml diff --git a/androidApp/src/playstore/AndroidManifest.xml b/androidApp/src/playstore/AndroidManifest.xml new file mode 100644 index 00000000..2468b6c5 --- /dev/null +++ b/androidApp/src/playstore/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt index 364def91..5c7a8164 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -10,4 +10,5 @@ actual object AppFeaturePolicy { actual val heroTrailerPlaybackSupported: Boolean = true actual val inAppUpdaterEnabled: Boolean = true actual val imdbRatingLogoEnabled: Boolean = true + actual val mediaPlaybackForegroundServiceEnabled: Boolean = true } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt index c5fdf7ca..f89986bb 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt @@ -20,6 +20,7 @@ import android.os.Looper import android.os.SystemClock import android.util.Log import com.nuvio.app.R +import com.nuvio.app.core.build.AppFeaturePolicy import java.io.ByteArrayOutputStream import java.lang.ref.WeakReference import java.net.HttpURLConnection @@ -106,7 +107,9 @@ internal class AndroidPlayerNowPlayingController( get() = !released && metadata != null init { - createNotificationChannel(appContext) + if (AppFeaturePolicy.mediaPlaybackForegroundServiceEnabled) { + createNotificationChannel(appContext) + } AndroidNowPlayingActionDispatcher.register(this) } @@ -288,6 +291,7 @@ internal class AndroidPlayerNowPlayingController( } private fun publishNotification() { + if (!AppFeaturePolicy.mediaPlaybackForegroundServiceEnabled) return val currentMetadata = metadata ?: return val notification = buildNotification( context = appContext, @@ -377,6 +381,7 @@ class PlayerNowPlayingService : Service() { companion object { internal fun publish(context: Context, notification: Notification) { + if (!AppFeaturePolicy.mediaPlaybackForegroundServiceEnabled) return PlayerNowPlayingServiceState.notification = notification val intent = Intent(context, PlayerNowPlayingService::class.java) .setAction(ACTION_START_FOREGROUND) @@ -394,6 +399,7 @@ class PlayerNowPlayingService : Service() { } internal fun hide(context: Context) { + if (!AppFeaturePolicy.mediaPlaybackForegroundServiceEnabled) return PlayerNowPlayingServiceState.notification = null runCatching { context.stopService(Intent(context, PlayerNowPlayingService::class.java)) } context.getSystemService(NotificationManager::class.java) diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt index f0ee56ff..524c9312 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -3,11 +3,12 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = false actual val supportersContributorsPageEnabled: Boolean = true - actual val accountDeletionEnabled: Boolean = false + actual val accountDeletionEnabled: Boolean = true actual val personalMediaAddonCopyEnabled: Boolean = false actual val p2pEnabled: Boolean = true actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = false + actual val mediaPlaybackForegroundServiceEnabled: Boolean = false } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index bee00f3b..affcc835 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -432,6 +432,7 @@ Playback Plugins Poster Card Style + Privacy Policy Settings Streams STREAMS @@ -454,6 +455,7 @@ GENERAL Manage available integrations Manage episode release alerts and send a test notification. + Learn how Nuvio handles your data Stream result display and badge URL rules. Change to a different profile. Switch Profile diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt index ec7ed177..f080e9a7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt @@ -15,4 +15,5 @@ expect object AppFeaturePolicy { val heroTrailerPlaybackSupported: Boolean val inAppUpdaterEnabled: Boolean val imdbRatingLogoEnabled: Boolean + val mediaPlaybackForegroundServiceEnabled: Boolean } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index a2676246..946819c2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -15,10 +15,12 @@ import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.People import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Policy import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.nuvio.app.core.build.AppVersionConfig @@ -32,6 +34,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_integrations import nuvio.composeapp.generated.resources.compose_settings_page_licenses_attributions import nuvio.composeapp.generated.resources.compose_settings_page_notifications import nuvio.composeapp.generated.resources.compose_settings_page_playback +import nuvio.composeapp.generated.resources.compose_settings_page_privacy_policy import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors import nuvio.composeapp.generated.resources.compose_settings_root_account_description import nuvio.composeapp.generated.resources.compose_settings_root_appearance_description @@ -43,6 +46,7 @@ import nuvio.composeapp.generated.resources.compose_settings_root_downloads_titl import nuvio.composeapp.generated.resources.compose_settings_root_general_section import nuvio.composeapp.generated.resources.compose_settings_root_integrations_description import nuvio.composeapp.generated.resources.compose_settings_root_notifications_description +import nuvio.composeapp.generated.resources.compose_settings_root_privacy_policy_description import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_description import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_title import nuvio.composeapp.generated.resources.compose_settings_root_trakt_description @@ -59,6 +63,8 @@ import nuvio.composeapp.generated.resources.about_supporters_contributors_subtit import nuvio.composeapp.generated.resources.about_licenses_attributions_subtitle import org.jetbrains.compose.resources.stringResource +private const val PRIVACY_POLICY_URL = "https://nuvio.tv/privacy-policy" + internal fun LazyListScope.settingsRootContent( isTablet: Boolean, onPlaybackClick: () -> Unit, @@ -177,6 +183,7 @@ internal fun LazyListScope.settingsRootContent( } if (showAboutSection) { item { + val uriHandler = LocalUriHandler.current SettingsSection( title = stringResource(Res.string.compose_settings_root_about_section), isTablet = isTablet, @@ -192,6 +199,14 @@ internal fun LazyListScope.settingsRootContent( ) SettingsGroupDivider(isTablet = isTablet) } + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_privacy_policy), + description = stringResource(Res.string.compose_settings_root_privacy_policy_description), + icon = Icons.Rounded.Policy, + isTablet = isTablet, + onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }, + ) + SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( title = stringResource(Res.string.compose_settings_page_licenses_attributions), description = stringResource(Res.string.about_licenses_attributions_subtitle), diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt index bc70435f..68dfa94f 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt @@ -10,4 +10,5 @@ actual object AppFeaturePolicy { actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = true + actual val mediaPlaybackForegroundServiceEnabled: Boolean = false } diff --git a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt index 6cc679aa..9cb21827 100644 --- a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt +++ b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -10,4 +10,5 @@ actual object AppFeaturePolicy { actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = false + actual val mediaPlaybackForegroundServiceEnabled: Boolean = false } diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt index 526130b9..0afebb29 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -10,4 +10,5 @@ actual object AppFeaturePolicy { actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = true + actual val mediaPlaybackForegroundServiceEnabled: Boolean = false } From b3f22a6be2f9ec49c02505e596edae01b6d48298 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:34:08 +0530 Subject: [PATCH 5/6] feat(auth): register official client devices --- .../auth/DeviceSessionRegistration.android.kt | 19 ++++ .../commonMain/kotlin/com/nuvio/app/App.kt | 7 ++ .../core/auth/DeviceSessionRegistration.kt | 90 +++++++++++++++++++ .../auth/DeviceSessionRegistrationTest.kt | 36 ++++++++ .../auth/DeviceSessionRegistration.ios.kt | 17 ++++ 5 files changed, 169 insertions(+) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistrationTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.android.kt new file mode 100644 index 00000000..3779c281 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.android.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.core.auth + +import android.os.Build + +internal actual fun currentDeviceClientMetadata(): DeviceClientMetadata { + val deviceName = formatDeviceName( + manufacturer = Build.MANUFACTURER.orEmpty(), + model = Build.MODEL.orEmpty(), + fallback = "Android device", + ) + val osVersion = Build.VERSION.RELEASE.orEmpty() + .trim() + .ifBlank { Build.VERSION.SDK_INT.toString() } + + return DeviceClientMetadata( + deviceName = deviceName, + platform = "Android $osVersion", + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index dd8cc3e7..d0d41f53 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -86,6 +86,7 @@ import coil3.svg.SvgDecoder import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.auth.DeviceSessionRegistration import com.nuvio.app.core.deeplink.AppDeepLink import com.nuvio.app.core.deeplink.AppDeepLinkRepository import com.nuvio.app.core.network.NetworkCondition @@ -488,6 +489,11 @@ fun App( NetworkStatusRepository.uiState }.collectAsStateWithLifecycle() + LaunchedEffect(authState) { + if (!ownsAppRuntime) return@LaunchedEffect + DeviceSessionRegistration.registerIfAuthenticated(force = true) + } + LaunchedEffect( profileState.activeProfile?.profileIndex, profileState.activeProfile?.name, @@ -1051,6 +1057,7 @@ private fun MainAppContent( if (!ownsAppRuntime) return@LaunchedEffect AppForegroundMonitor.events().collect { NetworkStatusRepository.requestForegroundRefresh() + DeviceSessionRegistration.registerIfAuthenticated() } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.kt new file mode 100644 index 00000000..df785b81 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.kt @@ -0,0 +1,90 @@ +package com.nuvio.app.core.auth + +import co.touchlab.kermit.Logger +import com.nuvio.app.core.build.AppVersionConfig +import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.sync.SyncClientIdentity +import io.github.jan.supabase.postgrest.postgrest +import io.github.jan.supabase.postgrest.rpc +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.time.Duration.Companion.minutes +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +private const val CLIENT_NAME = "Nuvio Mobile" +private val REGISTRATION_INTERVAL = 15.minutes + +internal data class DeviceClientMetadata( + val deviceName: String, + val platform: String, +) + +internal expect fun currentDeviceClientMetadata(): DeviceClientMetadata + +object DeviceSessionRegistration { + private val log = Logger.withTag("DeviceSessionRegistration") + private val registrationMutex = Mutex() + private var lastRegistration: TimeMark? = null + + suspend fun registerIfAuthenticated(force: Boolean = false): Boolean { + val authState = AuthRepository.state.value as? AuthState.Authenticated ?: return false + if (authState.isAnonymous) return false + + return registrationMutex.withLock { + if (!force && lastRegistration?.elapsedNow()?.let { it < REGISTRATION_INTERVAL } == true) { + return@withLock true + } + + val metadata = currentDeviceClientMetadata() + val params = buildDeviceRegistrationParams( + installationId = SyncClientIdentity.currentClientId(), + clientVersion = AppVersionConfig.VERSION_NAME, + metadata = metadata, + ) + + try { + SupabaseProvider.client.postgrest.rpc("register_current_device", params) + lastRegistration = TimeSource.Monotonic.markNow() + true + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.w(error) { "Device session registration failed" } + false + } + } + } +} + +internal fun buildDeviceRegistrationParams( + installationId: String, + clientVersion: String, + metadata: DeviceClientMetadata, +): JsonObject = buildJsonObject { + put("p_installation_id", installationId) + put("p_client_name", CLIENT_NAME) + put("p_client_version", clientVersion) + put("p_platform", metadata.platform) + put("p_device_name", metadata.deviceName) +} + +internal fun formatDeviceName( + manufacturer: String, + model: String, + fallback: String, +): String { + val normalizedManufacturer = manufacturer.trim() + val normalizedModel = model.trim() + + return when { + normalizedModel.isBlank() -> fallback + normalizedManufacturer.isBlank() -> normalizedModel + normalizedModel.startsWith(normalizedManufacturer, ignoreCase = true) -> normalizedModel + else -> "$normalizedManufacturer $normalizedModel" + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistrationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistrationTest.kt new file mode 100644 index 00000000..065f43d4 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistrationTest.kt @@ -0,0 +1,36 @@ +package com.nuvio.app.core.auth + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DeviceSessionRegistrationTest { + @Test + fun buildsOfficialClientRegistrationPayload() { + val params = buildDeviceRegistrationParams( + installationId = "nuvio-mobile-installation", + clientVersion = "1.2.3", + metadata = DeviceClientMetadata( + deviceName = "Google Pixel 9", + platform = "Android 16", + ), + ) + + assertEquals("nuvio-mobile-installation", params.getValue("p_installation_id").toString().trim('"')) + assertEquals("Nuvio Mobile", params.getValue("p_client_name").toString().trim('"')) + assertEquals("1.2.3", params.getValue("p_client_version").toString().trim('"')) + assertEquals("Android 16", params.getValue("p_platform").toString().trim('"')) + assertEquals("Google Pixel 9", params.getValue("p_device_name").toString().trim('"')) + } + + @Test + fun combinesManufacturerAndModelWithoutRepeatingManufacturer() { + assertEquals( + "Samsung SM-S918B", + formatDeviceName("Samsung", "SM-S918B", "Android device"), + ) + assertEquals( + "Google Pixel 9", + formatDeviceName("Google", "Google Pixel 9", "Android device"), + ) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.ios.kt new file mode 100644 index 00000000..0259ebf2 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.ios.kt @@ -0,0 +1,17 @@ +package com.nuvio.app.core.auth + +import platform.UIKit.UIDevice + +internal actual fun currentDeviceClientMetadata(): DeviceClientMetadata { + val device = UIDevice.currentDevice + val deviceName = device.name + .trim() + .ifBlank { device.localizedModel.trim() } + .ifBlank { "Apple device" } + val platform = "${device.systemName()} ${device.systemVersion}".trim() + + return DeviceClientMetadata( + deviceName = deviceName, + platform = platform, + ) +} From 88d3cbdfac71b86cdabfe902eddc0b67ea824489 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:12:18 +0530 Subject: [PATCH 6/6] bump version --- iosApp/Configuration/Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 00822e62..f5aefdc3 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=100 -MARKETING_VERSION=0.3.1 +CURRENT_PROJECT_VERSION=101 +MARKETING_VERSION=0.3.2