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/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/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt
index 5303fe9e..a73f2585 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.os.SystemClock
@@ -39,6 +40,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
@@ -254,6 +256,9 @@ 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 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
@@ -342,6 +347,9 @@ private fun ExoPlayerSurface(
shouldNormalizeCuePositionProvider = {
latestExternalSubtitleMimeType.value == MimeTypes.TEXT_VTT
},
+ videoBoundsFractionProvider = {
+ playerViewRef?.videoBoundsFraction(latestVideoAspectRatio.value)
+ },
)
.setExtensionRendererMode(effectiveDecoderPriority)
.setEnableDecoderFallback(true)
@@ -449,7 +457,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()
@@ -580,10 +587,6 @@ private fun ExoPlayerSurface(
dispatchExoPlayerSnapshot()
}
- override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) {
- latestOnSnapshot.value(exoPlayer.snapshot())
- }
-
override fun onIsPlayingChanged(isPlaying: Boolean) {
Log.i(
PLAYER_DIAGNOSTIC_TAG,
@@ -608,6 +611,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")
@@ -1794,11 +1804,50 @@ private fun ExoPlayer.logCurrentTracks(context: String) {
Log.d(TAG, "--- end logCurrentTracks ---")
}
+@androidx.annotation.OptIn(UnstableApi::class)
+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 frameWidth = contentFrame.width.toFloat()
+ val frameHeight = contentFrame.height.toFloat()
+ 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(
+ 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,
@@ -1810,6 +1859,7 @@ private class SubtitleOffsetRenderersFactory(
val normalizingOutput = CueNormalizingTextOutput(
delegate = output,
shouldNormalizeCuePositionProvider = shouldNormalizeCuePositionProvider,
+ videoBoundsFractionProvider = videoBoundsFractionProvider,
)
val startIndex = out.size
super.buildTextRenderers(context, normalizingOutput, outputLooper, extensionRendererMode, out)
@@ -1825,6 +1875,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)
@@ -1841,9 +1892,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
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 65440f1b..40b2142e 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -434,6 +434,7 @@
Playback
Plugins
Poster Card Style
+ Privacy Policy
Settings
Streams
STREAMS
@@ -457,6 +458,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/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index 50816838..29176278 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
@@ -498,6 +499,11 @@ fun App(
NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle()
+ LaunchedEffect(authState) {
+ if (!ownsAppRuntime) return@LaunchedEffect
+ DeviceSessionRegistration.registerIfAuthenticated(force = true)
+ }
+
LaunchedEffect(
profileState.activeProfile?.profileIndex,
profileState.activeProfile?.name,
@@ -1063,6 +1069,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/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/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
+}
+
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 17f3750c..ce6d6294 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
@@ -16,10 +16,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
@@ -33,6 +35,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
@@ -44,6 +47,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_tracking_description
@@ -60,6 +64,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,
@@ -178,6 +184,7 @@ internal fun LazyListScope.settingsRootContent(
}
if (showAboutSection) {
item {
+ val uriHandler = LocalUriHandler.current
SettingsSection(
title = stringResource(Res.string.compose_settings_root_about_section),
isTablet = isTablet,
@@ -193,6 +200,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/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/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
}
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,
+ )
+}
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