mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Compare commits
8 Commits
ae4c2b34f4
...
63a8a108e2
| Author | SHA1 | Date | |
|---|---|---|---|
| 63a8a108e2 | |||
| 5d12b97922 | |||
| fe0b76a0ad | |||
| a5bb1f84dc | |||
| f925ca57ea | |||
| a41836b925 | |||
| 5bec125f56 | |||
| 3a748397f8 |
@@ -19,7 +19,7 @@ android {
|
||||
applicationId = "hu.bbara.purefin.tv"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
versionCode = 2000001
|
||||
versionCode = 2000003
|
||||
versionName = "0.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
@@ -23,7 +25,11 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/purefin_logo_round"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Purefin">
|
||||
<meta-data
|
||||
android:name="hu.bbara.purefin.UPDATE_MANIFEST_URL"
|
||||
android:value="http://purefin.t.bbara.hu/app-tv/update.json" />
|
||||
<activity
|
||||
android:name="hu.bbara.purefin.tv.TvActivity"
|
||||
android:exported="true"
|
||||
@@ -34,6 +40,9 @@
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<receiver
|
||||
android:name="hu.bbara.purefin.update.AppUpdateInstallReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -13,6 +13,7 @@ import hu.bbara.purefin.navigation.tvLibrarySection
|
||||
import hu.bbara.purefin.navigation.tvLoginSection
|
||||
import hu.bbara.purefin.navigation.tvMovieSection
|
||||
import hu.bbara.purefin.navigation.tvPlayerSection
|
||||
import hu.bbara.purefin.navigation.tvSettingsSection
|
||||
import hu.bbara.purefin.navigation.tvSeriesSection
|
||||
|
||||
@Module
|
||||
@@ -60,4 +61,10 @@ object TvNavigationModule {
|
||||
fun provideTvLibraryEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
|
||||
tvLibrarySection()
|
||||
}
|
||||
|
||||
@IntoSet
|
||||
@Provides
|
||||
fun provideTvSettingsEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
|
||||
tvSettingsSection()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import hu.bbara.purefin.ui.screen.login.LoginScreen
|
||||
import hu.bbara.purefin.ui.screen.TvAppScreen
|
||||
import hu.bbara.purefin.ui.screen.library.TvLibraryScreen
|
||||
import hu.bbara.purefin.ui.screen.player.TvPlayerScreen
|
||||
import hu.bbara.purefin.ui.screen.settings.TvSettingsScreen
|
||||
|
||||
fun EntryProviderScope<Route>.tvHomeSection() {
|
||||
entry<Route.Home> {
|
||||
@@ -62,3 +63,9 @@ fun EntryProviderScope<Route>.tvLibrarySection() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun EntryProviderScope<Route>.tvSettingsSection() {
|
||||
entry<Route.SettingsRoute> {
|
||||
TvSettingsScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package hu.bbara.purefin.tv
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -11,6 +12,9 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -20,6 +24,8 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
@@ -38,10 +44,12 @@ import coil3.util.DebugLogger
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import hu.bbara.purefin.data.SessionBootstrapper
|
||||
import hu.bbara.purefin.data.UserSessionRepository
|
||||
import hu.bbara.purefin.feature.update.AppUpdateInfo
|
||||
import hu.bbara.purefin.feature.update.AppUpdateViewModel
|
||||
import hu.bbara.purefin.jellyfin.JellyfinAuthInterceptor
|
||||
import hu.bbara.purefin.navigation.NavigationCommand
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import hu.bbara.purefin.jellyfin.JellyfinAuthInterceptor
|
||||
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.ui.screen.login.LoginScreen
|
||||
@@ -135,10 +143,13 @@ class TvActivity : ComponentActivity() {
|
||||
fun MainApp(
|
||||
userSessionRepository: UserSessionRepository,
|
||||
entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>,
|
||||
navigationManager: NavigationManager
|
||||
navigationManager: NavigationManager,
|
||||
updateViewModel: AppUpdateViewModel = hiltViewModel()
|
||||
) {
|
||||
var sessionLoaded by remember { mutableStateOf(false) }
|
||||
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
|
||||
val availableUpdate by updateViewModel.availableUpdate.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
userSessionRepository.isLoggedIn.collect {
|
||||
@@ -146,12 +157,19 @@ class TvActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionLoaded) {
|
||||
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
||||
return
|
||||
LaunchedEffect(updateViewModel) {
|
||||
updateViewModel.checkForUpdates(showUpToDateMessage = false)
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
LaunchedEffect(updateViewModel, context) {
|
||||
updateViewModel.snackbarMessages.collect { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionLoaded) {
|
||||
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
||||
} else if (isLoggedIn) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
|
||||
val appEntryProvider = entryProvider {
|
||||
@@ -213,5 +231,44 @@ class TvActivity : ComponentActivity() {
|
||||
} else {
|
||||
LoginScreen()
|
||||
}
|
||||
|
||||
availableUpdate?.let { update ->
|
||||
TvUpdateAvailableDialog(
|
||||
update = update,
|
||||
onAccept = updateViewModel::acceptUpdate,
|
||||
onDecline = updateViewModel::declineUpdate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvUpdateAvailableDialog(
|
||||
update: AppUpdateInfo,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit
|
||||
) {
|
||||
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
|
||||
val releaseNotes = update.releaseNotes?.takeIf { it.isNotBlank() }
|
||||
val updateText = if (releaseNotes == null) {
|
||||
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}"
|
||||
} else {
|
||||
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}\n\n$releaseNotes"
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDecline,
|
||||
title = { Text("Update available") },
|
||||
text = { Text(updateText) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onAccept) {
|
||||
Text("Install")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDecline) {
|
||||
Text("Not now")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.Movie
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Tv
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -27,6 +28,7 @@ import hu.bbara.purefin.ui.screen.home.TvHomeScreen
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestinationItem
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvNavigationDrawer
|
||||
import hu.bbara.purefin.ui.screen.library.TvLibraryScreen
|
||||
import hu.bbara.purefin.ui.screen.settings.TvSettingsScreen
|
||||
|
||||
@Composable
|
||||
fun TvAppScreen(
|
||||
@@ -62,7 +64,11 @@ fun TvAppScreen(
|
||||
LibraryKind.SERIES -> Icons.Outlined.Tv
|
||||
}
|
||||
)
|
||||
}
|
||||
} + TvDrawerDestinationItem(
|
||||
destination = Route.SettingsRoute,
|
||||
label = "Settings",
|
||||
icon = Icons.Outlined.Settings
|
||||
)
|
||||
}
|
||||
|
||||
LifecycleResumeEffect(Unit) {
|
||||
@@ -88,6 +94,12 @@ fun TvAppScreen(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
entry<Route.SettingsRoute> {
|
||||
TvSettingsScreen(
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TvNavigationDrawer(
|
||||
@@ -97,7 +109,7 @@ fun TvAppScreen(
|
||||
if (selectedDestination != destination) {
|
||||
backStack.clear()
|
||||
backStack.add(Route.Home)
|
||||
if (destination is Route.LibraryRoute) {
|
||||
if (destination != Route.Home) {
|
||||
backStack.add(destination)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package hu.bbara.purefin.ui.screen.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.feature.settings.SettingsViewModel
|
||||
import hu.bbara.purefin.settings.SettingsOptions
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun TvSettingsScreen(
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
onBack: () -> Unit = viewModel::onBack,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = {
|
||||
TvSettingsTopBar(onBack = onBack)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = "Playback",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
SettingsOptions.numberSettings.forEach { option ->
|
||||
item(key = option.key) {
|
||||
val value by viewModel.value(option).collectAsState(initial = option.defaultValue)
|
||||
TvNumberSettingItem(
|
||||
title = option.title,
|
||||
value = value,
|
||||
valueRange = option.valueRange,
|
||||
onValueChange = { viewModel.set(option, it) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsOptions.booleanSettings.forEach { option ->
|
||||
item(key = option.key) {
|
||||
val value by viewModel.value(option).collectAsState(initial = option.defaultValue)
|
||||
TvBooleanSettingItem(
|
||||
title = option.title,
|
||||
value = value,
|
||||
onValueChange = { viewModel.set(option, it) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsOptions.stringSettings.forEach { option ->
|
||||
item(key = option.key) {
|
||||
val value by viewModel.value(option).collectAsState(initial = option.defaultValue)
|
||||
TvStringSettingItem(
|
||||
title = option.title,
|
||||
value = value,
|
||||
onValueChange = { viewModel.set(option, it) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvSettingsTopBar(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvNumberSettingItem(
|
||||
title: String,
|
||||
value: Double,
|
||||
valueRange: ClosedFloatingPointRange<Double>,
|
||||
onValueChange: (Double) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var sliderValue by remember(value) { mutableStateOf(value.toFloat()) }
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = String.format(Locale.US, "%.1f", sliderValue),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Slider(
|
||||
value = sliderValue,
|
||||
onValueChange = { sliderValue = it },
|
||||
onValueChangeFinished = { onValueChange(sliderValue.toDouble()) },
|
||||
valueRange = valueRange.start.toFloat()..valueRange.endInclusive.toFloat(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvBooleanSettingItem(
|
||||
title: String,
|
||||
value: Boolean,
|
||||
onValueChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onValueChange(!value) }
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Switch(
|
||||
checked = value,
|
||||
onCheckedChange = onValueChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvStringSettingItem(
|
||||
title: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(title) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words
|
||||
),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ android {
|
||||
applicationId = "hu.bbara.purefin"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
versionCode = 1000002
|
||||
versionCode = 1000005
|
||||
versionName = "0.1"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -40,6 +40,7 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".PurefinApplication"
|
||||
@@ -16,7 +18,11 @@
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Purefin">
|
||||
<meta-data
|
||||
android:name="hu.bbara.purefin.UPDATE_MANIFEST_URL"
|
||||
android:value="http://purefin.t.bbara.hu/app/update.json" />
|
||||
<activity
|
||||
android:name=".PurefinActivity"
|
||||
android:exported="true"
|
||||
@@ -36,6 +42,9 @@
|
||||
android:name="hu.bbara.purefin.download.PurefinDownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<receiver
|
||||
android:name="hu.bbara.purefin.update.AppUpdateInstallReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -9,6 +9,7 @@ import hu.bbara.purefin.ui.screen.AppScreen
|
||||
import hu.bbara.purefin.ui.screen.episode.EpisodeScreen
|
||||
import hu.bbara.purefin.ui.screen.home.components.search.SearchScreen
|
||||
import hu.bbara.purefin.ui.screen.movie.MovieScreen
|
||||
import hu.bbara.purefin.ui.screen.settings.SettingsScreen
|
||||
import hu.bbara.purefin.ui.screen.series.SeriesScreen
|
||||
|
||||
fun EntryProviderScope<Route>.appRouteEntryBuilder() {
|
||||
@@ -53,4 +54,7 @@ fun EntryProviderScope<Route>.appRouteEntryBuilder() {
|
||||
entry<Route.LoginRoute> {
|
||||
LoginScreen()
|
||||
}
|
||||
entry<Route.SettingsRoute> {
|
||||
SettingsScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -19,6 +24,8 @@ import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import hu.bbara.purefin.feature.browse.home.AppViewModel
|
||||
import hu.bbara.purefin.feature.update.AppUpdateInfo
|
||||
import hu.bbara.purefin.feature.update.AppUpdateViewModel
|
||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.ui.screen.download.DownloadsScreen
|
||||
import hu.bbara.purefin.ui.screen.home.HomeScreen
|
||||
@@ -28,6 +35,7 @@ import kotlinx.serialization.Serializable
|
||||
@Composable
|
||||
fun AppScreen(
|
||||
viewModel: AppViewModel = hiltViewModel(),
|
||||
updateViewModel: AppUpdateViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val libraries by viewModel.libraries.collectAsState()
|
||||
@@ -36,7 +44,10 @@ fun AppScreen(
|
||||
val continueWatching by viewModel.continueWatching.collectAsState()
|
||||
val nextUp by viewModel.nextUp.collectAsState()
|
||||
val isRefreshing by viewModel.isRefreshing.collectAsState()
|
||||
val isCheckingForUpdates by updateViewModel.isCheckingForUpdates.collectAsState()
|
||||
val availableUpdate by updateViewModel.availableUpdate.collectAsState()
|
||||
val navigationManager = LocalNavigationManager.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val backStack = rememberNavBackStack(AppTabRoute.Home) as NavBackStack<AppTabRoute>
|
||||
@@ -48,6 +59,12 @@ fun AppScreen(
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
LaunchedEffect(updateViewModel) {
|
||||
updateViewModel.snackbarMessages.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
|
||||
val onTabSelected = remember(backStack) {
|
||||
{ selectedIndex: Int ->
|
||||
val route = selectedIndex.toAppTabRoute()
|
||||
@@ -75,9 +92,12 @@ fun AppScreen(
|
||||
)
|
||||
},
|
||||
onProfileClick = {},
|
||||
onSettingsClick = {},
|
||||
onCheckForUpdates = { updateViewModel.checkForUpdates() },
|
||||
isCheckingForUpdates = isCheckingForUpdates,
|
||||
onSettingsClick = viewModel::openSettings,
|
||||
onLogoutClick = viewModel::logout,
|
||||
onSearchClick = viewModel::openSearch,
|
||||
snackbarHostState = snackbarHostState,
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = onTabSelected,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
@@ -120,6 +140,14 @@ fun AppScreen(
|
||||
entryProvider = tabEntryProvider,
|
||||
modifier = modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
availableUpdate?.let { update ->
|
||||
UpdateAvailableDialog(
|
||||
update = update,
|
||||
onAccept = updateViewModel::acceptUpdate,
|
||||
onDecline = updateViewModel::declineUpdate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -153,4 +181,35 @@ private fun appTabMetadata(route: AppTabRoute): Map<String, Any> =
|
||||
private fun Map<String, Any>.appTabIndex(): Int =
|
||||
this[APP_TAB_INDEX_METADATA] as? Int ?: 0
|
||||
|
||||
@Composable
|
||||
private fun UpdateAvailableDialog(
|
||||
update: AppUpdateInfo,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit
|
||||
) {
|
||||
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
|
||||
val releaseNotes = update.releaseNotes?.takeIf { it.isNotBlank() }
|
||||
val updateText = if (releaseNotes == null) {
|
||||
"Purefin $versionLabel is available.\n\nVersion code: ${update.versionCode}"
|
||||
} else {
|
||||
"Purefin $versionLabel is available.\n\nVersion code: ${update.versionCode}\n\n$releaseNotes"
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDecline,
|
||||
title = { Text("Update available") },
|
||||
text = { Text(updateText) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onAccept) {
|
||||
Text("Install")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDecline) {
|
||||
Text("Not now")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private const val APP_TAB_INDEX_METADATA = "app_tab_index"
|
||||
|
||||
@@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import hu.bbara.purefin.ui.model.LibraryUiModel
|
||||
@@ -25,9 +27,12 @@ fun HomeScreen(
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||
onProfileClick: () -> Unit,
|
||||
onCheckForUpdates: () -> Unit,
|
||||
isCheckingForUpdates: Boolean,
|
||||
onSettingsClick: () -> Unit,
|
||||
onLogoutClick: () -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
selectedTab: Int,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -41,10 +46,13 @@ fun HomeScreen(
|
||||
HomeTopBar(
|
||||
onSearchClick = onSearchClick,
|
||||
onProfileClick = onProfileClick,
|
||||
onCheckForUpdates = onCheckForUpdates,
|
||||
isCheckingForUpdates = isCheckingForUpdates,
|
||||
onSettingsClick = onSettingsClick,
|
||||
onLogoutClick = onLogoutClick
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
bottomBar = {
|
||||
AppBottomBar(
|
||||
selectedTab = selectedTab,
|
||||
|
||||
@@ -19,6 +19,8 @@ import androidx.compose.ui.unit.dp
|
||||
fun HomeTopBar(
|
||||
onSearchClick: () -> Unit,
|
||||
onProfileClick: () -> Unit,
|
||||
onCheckForUpdates: () -> Unit,
|
||||
isCheckingForUpdates: Boolean,
|
||||
onSettingsClick: () -> Unit,
|
||||
onLogoutClick: () -> Unit,
|
||||
) {
|
||||
@@ -44,6 +46,14 @@ fun HomeTopBar(
|
||||
onProfileClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isCheckingForUpdates) "Checking updates..." else "Check for updates") },
|
||||
enabled = !isCheckingForUpdates,
|
||||
onClick = {
|
||||
isProfileMenuExpanded = false
|
||||
onCheckForUpdates()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Settings") },
|
||||
onClick = {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package hu.bbara.purefin.ui.screen.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.feature.settings.SettingsViewModel
|
||||
import hu.bbara.purefin.settings.SettingsOptions
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarIconButton
|
||||
import hu.bbara.purefin.ui.screen.settings.components.BooleanSettingItem
|
||||
import hu.bbara.purefin.ui.screen.settings.components.NumberSettingItem
|
||||
import hu.bbara.purefin.ui.screen.settings.components.StringSettingItem
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = {
|
||||
SettingsTopBar(onBack = viewModel::onBack)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = "Playback",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
SettingsOptions.numberSettings.forEach { option ->
|
||||
item(key = option.key) {
|
||||
val value by viewModel.value(option).collectAsState(initial = option.defaultValue)
|
||||
NumberSettingItem(
|
||||
title = option.title,
|
||||
value = value,
|
||||
valueRange = option.valueRange,
|
||||
onValueChange = { viewModel.set(option, it) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsOptions.booleanSettings.forEach { option ->
|
||||
item(key = option.key) {
|
||||
val value by viewModel.value(option).collectAsState(initial = option.defaultValue)
|
||||
BooleanSettingItem(
|
||||
title = option.title,
|
||||
value = value,
|
||||
onValueChange = { viewModel.set(option, it) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsOptions.stringSettings.forEach { option ->
|
||||
item(key = option.key) {
|
||||
val value by viewModel.value(option).collectAsState(initial = option.defaultValue)
|
||||
StringSettingItem(
|
||||
title = option.title,
|
||||
value = value,
|
||||
onValueChange = { viewModel.set(option, it) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsTopBar(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
DefaultTopBar(
|
||||
leftActions = {
|
||||
DefaultTopBarIconButton(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
onClick = onBack
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package hu.bbara.purefin.ui.screen.settings.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun BooleanSettingItem(
|
||||
title: String,
|
||||
value: Boolean,
|
||||
onValueChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier.Companion
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = value,
|
||||
onCheckedChange = onValueChange
|
||||
)
|
||||
},
|
||||
modifier = modifier.clickable { onValueChange(!value) }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package hu.bbara.purefin.ui.screen.settings.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun NumberSettingItem(
|
||||
title: String,
|
||||
value: Double,
|
||||
valueRange: ClosedFloatingPointRange<Double>,
|
||||
onValueChange: (Double) -> Unit,
|
||||
modifier: Modifier = Modifier.Companion
|
||||
) {
|
||||
var sliderValue by remember(value) { mutableStateOf(value.toFloat()) }
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = String.Companion.format(Locale.US, "%.1f", sliderValue),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Slider(
|
||||
value = sliderValue,
|
||||
onValueChange = { sliderValue = it },
|
||||
onValueChangeFinished = { onValueChange(sliderValue.toDouble()) },
|
||||
valueRange = valueRange.start.toFloat()..valueRange.endInclusive.toFloat(),
|
||||
modifier = Modifier.Companion.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package hu.bbara.purefin.ui.screen.settings.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun StringSettingItem(
|
||||
title: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier.Companion
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(title) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Companion.Words
|
||||
),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
10
core-model/src/main/java/hu/bbara/purefin/model/Settings.kt
Normal file
10
core-model/src/main/java/hu/bbara/purefin/model/Settings.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package hu.bbara.purefin.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Settings(
|
||||
val numberSettings: Map<String, Double> = emptyMap(),
|
||||
val booleanSettings: Map<String, Boolean> = emptyMap(),
|
||||
val stringSettings: Map<String, String> = emptyMap()
|
||||
)
|
||||
@@ -256,6 +256,10 @@ class AppViewModel @Inject constructor(
|
||||
navigationManager.navigate(Route.HomeSearchRoute)
|
||||
}
|
||||
|
||||
fun openSettings() {
|
||||
navigationManager.navigate(Route.SettingsRoute)
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
userSessionRepository.setLoggedIn(false)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package hu.bbara.purefin.feature.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import hu.bbara.purefin.settings.BooleanSetting
|
||||
import hu.bbara.purefin.settings.NumberSetting
|
||||
import hu.bbara.purefin.settings.SettingsRepository
|
||||
import hu.bbara.purefin.settings.StringSetting
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
) : ViewModel() {
|
||||
|
||||
fun value(option: NumberSetting) = settingsRepository.value(option)
|
||||
|
||||
fun value(option: BooleanSetting) = settingsRepository.value(option)
|
||||
|
||||
fun value(option: StringSetting) = settingsRepository.value(option)
|
||||
|
||||
fun set(option: NumberSetting, value: Double) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.set(option, value)
|
||||
}
|
||||
}
|
||||
|
||||
fun set(option: BooleanSetting, value: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.set(option, value)
|
||||
}
|
||||
}
|
||||
|
||||
fun set(option: StringSetting, value: String) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.set(option, value)
|
||||
}
|
||||
}
|
||||
|
||||
fun onBack() {
|
||||
navigationManager.pop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package hu.bbara.purefin.feature.update
|
||||
|
||||
data class AppUpdateInfo(
|
||||
val versionCode: Long,
|
||||
val versionName: String?,
|
||||
val releaseNotes: String?,
|
||||
val apkUrl: String,
|
||||
val manifestUrl: String
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
package hu.bbara.purefin.feature.update
|
||||
|
||||
interface AppUpdateInstaller {
|
||||
suspend fun installUpdate(update: AppUpdateInfo): String
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package hu.bbara.purefin.feature.update
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import javax.inject.Inject
|
||||
|
||||
class AppUpdateRepository @Inject constructor(
|
||||
private val appVersionProvider: AppVersionProvider
|
||||
) {
|
||||
private val client = OkHttpClient()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun checkForUpdate(): AppUpdateInfo? {
|
||||
val manifestUrl = appVersionProvider.updateManifestUrl
|
||||
if (manifestUrl.isBlank()) {
|
||||
throw IllegalStateException("Update manifest URL not configured")
|
||||
}
|
||||
|
||||
val manifest = withContext(Dispatchers.IO) { fetchManifest(manifestUrl) }
|
||||
if (manifest.versionCode <= appVersionProvider.versionCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return AppUpdateInfo(
|
||||
versionCode = manifest.versionCode,
|
||||
versionName = manifest.versionName,
|
||||
releaseNotes = manifest.releaseNotes,
|
||||
apkUrl = manifest.apkUrl,
|
||||
manifestUrl = manifestUrl
|
||||
)
|
||||
}
|
||||
|
||||
private fun fetchManifest(manifestUrl: String): AppUpdateManifest {
|
||||
val request = Request.Builder()
|
||||
.url(manifestUrl)
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Update check failed: HTTP ${response.code}")
|
||||
}
|
||||
val body = response.body?.string() ?: throw IllegalStateException("Update manifest empty")
|
||||
return json.decodeFromString<AppUpdateManifest>(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class AppUpdateManifest(
|
||||
val versionCode: Long,
|
||||
val versionName: String? = null,
|
||||
val apkUrl: String,
|
||||
val releaseNotes: String? = null
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
package hu.bbara.purefin.feature.update
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AppUpdateViewModel @Inject constructor(
|
||||
private val appUpdateRepository: AppUpdateRepository,
|
||||
private val appUpdateInstaller: AppUpdateInstaller
|
||||
) : ViewModel() {
|
||||
|
||||
private val _isCheckingForUpdates = MutableStateFlow(false)
|
||||
val isCheckingForUpdates: StateFlow<Boolean> = _isCheckingForUpdates.asStateFlow()
|
||||
|
||||
private val _availableUpdate = MutableStateFlow<AppUpdateInfo?>(null)
|
||||
val availableUpdate: StateFlow<AppUpdateInfo?> = _availableUpdate.asStateFlow()
|
||||
|
||||
private val _snackbarMessages = MutableSharedFlow<String>()
|
||||
val snackbarMessages: SharedFlow<String> = _snackbarMessages.asSharedFlow()
|
||||
|
||||
private var isInstallingUpdate = false
|
||||
|
||||
fun checkForUpdates(showUpToDateMessage: Boolean = true) {
|
||||
if (_isCheckingForUpdates.value) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_isCheckingForUpdates.value = true
|
||||
try {
|
||||
val update = appUpdateRepository.checkForUpdate()
|
||||
if (update == null) {
|
||||
if (showUpToDateMessage) {
|
||||
_snackbarMessages.emit("Purefin is up to date")
|
||||
}
|
||||
} else {
|
||||
_availableUpdate.value = update
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_snackbarMessages.emit(e.message ?: "Update check failed")
|
||||
} finally {
|
||||
_isCheckingForUpdates.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun acceptUpdate() {
|
||||
val update = _availableUpdate.value ?: return
|
||||
if (isInstallingUpdate) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
isInstallingUpdate = true
|
||||
_availableUpdate.value = null
|
||||
try {
|
||||
_snackbarMessages.emit(appUpdateInstaller.installUpdate(update))
|
||||
} catch (e: Exception) {
|
||||
_snackbarMessages.emit(e.message ?: "Update install failed")
|
||||
} finally {
|
||||
isInstallingUpdate = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun declineUpdate() {
|
||||
_availableUpdate.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package hu.bbara.purefin.feature.update
|
||||
|
||||
interface AppVersionProvider {
|
||||
val versionCode: Long
|
||||
val updateManifestUrl: String
|
||||
}
|
||||
@@ -26,6 +26,9 @@ sealed interface Route : NavKey {
|
||||
@Serializable
|
||||
data object LoginRoute : Route
|
||||
|
||||
@Serializable
|
||||
data object SettingsRoute : Route
|
||||
|
||||
@Serializable
|
||||
data class PlayerRoute(val mediaId: String) : Route
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package hu.bbara.purefin.settings
|
||||
|
||||
sealed interface SettingOption<T> {
|
||||
val key: String
|
||||
val title: String
|
||||
val defaultValue: T
|
||||
}
|
||||
|
||||
data class NumberSetting(
|
||||
override val key: String,
|
||||
override val title: String,
|
||||
override val defaultValue: Double,
|
||||
val valueRange: ClosedFloatingPointRange<Double> = 0.0..100.0,
|
||||
) : SettingOption<Double>
|
||||
|
||||
data class BooleanSetting(
|
||||
override val key: String,
|
||||
override val title: String,
|
||||
override val defaultValue: Boolean
|
||||
) : SettingOption<Boolean>
|
||||
|
||||
data class StringSetting(
|
||||
override val key: String,
|
||||
override val title: String,
|
||||
override val defaultValue: String
|
||||
) : SettingOption<String>
|
||||
|
||||
object SettingsOptions {
|
||||
val defaultPlaybackSpeed = NumberSetting(
|
||||
key = "default_playback_speed",
|
||||
title = "Default playback speed",
|
||||
defaultValue = 1.0,
|
||||
valueRange = 0.5..2.0
|
||||
)
|
||||
|
||||
val confirmMobileDataPlayback = BooleanSetting(
|
||||
key = "confirm_mobile_data_playback",
|
||||
title = "Confirm mobile data playback",
|
||||
defaultValue = true
|
||||
)
|
||||
|
||||
val preferredAudioLanguage = StringSetting(
|
||||
key = "preferred_audio_language",
|
||||
title = "Preferred audio language",
|
||||
defaultValue = "English"
|
||||
)
|
||||
|
||||
val numberSettings = listOf(defaultPlaybackSpeed)
|
||||
val booleanSettings = listOf(confirmMobileDataPlayback)
|
||||
val stringSettings = listOf(preferredAudioLanguage)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package hu.bbara.purefin.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.dataStoreFile
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.model.Settings
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class SettingsModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSettingsDataStore(
|
||||
@ApplicationContext context: Context
|
||||
): DataStore<Settings> {
|
||||
return DataStoreFactory.create(
|
||||
serializer = SettingsSerializer,
|
||||
produceFile = { context.dataStoreFile("settings.json") },
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(
|
||||
produceNewData = { SettingsSerializer.defaultValue }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSettingsRepository(
|
||||
settingsDataStore: DataStore<Settings>
|
||||
): SettingsRepository {
|
||||
return SettingsRepository(settingsDataStore)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package hu.bbara.purefin.settings
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import hu.bbara.purefin.model.Settings
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
class SettingsRepository @Inject constructor(
|
||||
private val settingsDataStore: DataStore<Settings>
|
||||
) {
|
||||
val settings: Flow<Settings> = settingsDataStore.data
|
||||
|
||||
fun value(option: NumberSetting): Flow<Double> {
|
||||
return settings
|
||||
.map { it.numberSettings[option.key] ?: option.defaultValue }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
fun value(option: BooleanSetting): Flow<Boolean> {
|
||||
return settings
|
||||
.map { it.booleanSettings[option.key] ?: option.defaultValue }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
fun value(option: StringSetting): Flow<String> {
|
||||
return settings
|
||||
.map { it.stringSettings[option.key] ?: option.defaultValue }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
suspend fun set(option: NumberSetting, value: Double) {
|
||||
settingsDataStore.updateData { current ->
|
||||
current.copy(
|
||||
numberSettings = current.numberSettings + (option.key to value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun set(option: BooleanSetting, value: Boolean) {
|
||||
settingsDataStore.updateData { current ->
|
||||
current.copy(
|
||||
booleanSettings = current.booleanSettings + (option.key to value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun set(option: StringSetting, value: String) {
|
||||
settingsDataStore.updateData { current ->
|
||||
current.copy(
|
||||
stringSettings = current.stringSettings + (option.key to value)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package hu.bbara.purefin.settings
|
||||
|
||||
import androidx.datastore.core.CorruptionException
|
||||
import androidx.datastore.core.Serializer
|
||||
import hu.bbara.purefin.model.Settings
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
object SettingsSerializer : Serializer<Settings> {
|
||||
override val defaultValue: Settings
|
||||
get() = Settings()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): Settings {
|
||||
try {
|
||||
return Json.decodeFromString<Settings>(
|
||||
input.readBytes().decodeToString()
|
||||
)
|
||||
} catch (serialization: SerializationException) {
|
||||
throw CorruptionException("Unable to read Settings", serialization)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeTo(t: Settings, output: OutputStream) {
|
||||
output.write(
|
||||
Json.encodeToString(Settings.serializer(), t)
|
||||
.encodeToByteArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package hu.bbara.purefin.update
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import hu.bbara.purefin.feature.update.AppVersionProvider
|
||||
import javax.inject.Inject
|
||||
|
||||
class AndroidAppVersionProvider @Inject constructor(
|
||||
@ApplicationContext context: Context
|
||||
) : AppVersionProvider {
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
override val versionCode: Long
|
||||
get() = appContext.packageManager.currentPackageInfo().longVersionCode
|
||||
|
||||
override val updateManifestUrl: String
|
||||
get() = appContext.packageManager.currentApplicationInfo()
|
||||
.metaData
|
||||
?.getString(UPDATE_MANIFEST_URL_META_DATA)
|
||||
.orEmpty()
|
||||
|
||||
private fun PackageManager.currentPackageInfo(): PackageInfo =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getPackageInfo(appContext.packageName, PackageManager.PackageInfoFlags.of(0))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getPackageInfo(appContext.packageName, 0)
|
||||
}
|
||||
|
||||
private fun PackageManager.currentApplicationInfo(): ApplicationInfo =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getApplicationInfo(
|
||||
appContext.packageName,
|
||||
PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong())
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getApplicationInfo(appContext.packageName, PackageManager.GET_META_DATA)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val UPDATE_MANIFEST_URL_META_DATA = "hu.bbara.purefin.UPDATE_MANIFEST_URL"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package hu.bbara.purefin.update
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
|
||||
class AppUpdateInstallReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
val confirmIntent = intent.pendingUserActionIntent() ?: return
|
||||
confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try {
|
||||
context.startActivity(confirmIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e(TAG, "Install confirmation activity missing", e)
|
||||
}
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> Unit
|
||||
else -> {
|
||||
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
Log.e(TAG, "Install failed: $status $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Intent.pendingUserActionIntent(): Intent? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "AppUpdateInstaller"
|
||||
}
|
||||
}
|
||||
138
core/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt
Normal file
138
core/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt
Normal file
@@ -0,0 +1,138 @@
|
||||
package hu.bbara.purefin.update
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentSender
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import hu.bbara.purefin.feature.update.AppUpdateInfo
|
||||
import hu.bbara.purefin.feature.update.AppUpdateInstaller
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
class AndroidAppUpdateInstaller @Inject constructor(
|
||||
@ApplicationContext context: Context
|
||||
) : AppUpdateInstaller {
|
||||
private val appContext = context.applicationContext
|
||||
private val client = OkHttpClient()
|
||||
|
||||
override suspend fun installUpdate(update: AppUpdateInfo): String {
|
||||
if (!appContext.packageManager.canRequestPackageInstalls()) {
|
||||
openInstallPermissionSettings()
|
||||
return "Allow app installs, then check again"
|
||||
}
|
||||
|
||||
val apkFile = withContext(Dispatchers.IO) {
|
||||
downloadApk(update)
|
||||
.also { validateApk(it) }
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
commitInstallSession(apkFile, update.versionCode)
|
||||
}
|
||||
|
||||
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
|
||||
return "Downloaded ${appLabel()} $versionLabel"
|
||||
}
|
||||
|
||||
private fun downloadApk(update: AppUpdateInfo): File {
|
||||
val apkUrl = update.manifestUrl.toHttpUrl().resolve(update.apkUrl)
|
||||
?: throw IllegalStateException("APK URL invalid")
|
||||
val request = Request.Builder()
|
||||
.url(apkUrl)
|
||||
.build()
|
||||
val updateDir = appContext.cacheDir.resolve("updates")
|
||||
updateDir.mkdirs()
|
||||
updateDir.listFiles()?.forEach { it.delete() }
|
||||
val apkFile = File(updateDir, "purefin-${update.versionCode}.apk")
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("APK download failed: HTTP ${response.code}")
|
||||
}
|
||||
val body = response.body ?: throw IllegalStateException("APK download empty")
|
||||
body.byteStream().use { input ->
|
||||
apkFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return apkFile
|
||||
}
|
||||
|
||||
private fun validateApk(apkFile: File) {
|
||||
appContext.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)
|
||||
?: throw IllegalStateException("Downloaded file is not a valid APK")
|
||||
}
|
||||
|
||||
private fun commitInstallSession(apkFile: File, versionCode: Long) {
|
||||
val installer = appContext.packageManager.packageInstaller
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||
setAppPackageName(appContext.packageName)
|
||||
setInstallReason(PackageManager.INSTALL_REASON_USER)
|
||||
setSize(apkFile.length())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
setPackageSource(PackageInstaller.PACKAGE_SOURCE_DOWNLOADED_FILE)
|
||||
}
|
||||
}
|
||||
val sessionId = installer.createSession(params)
|
||||
try {
|
||||
installer.openSession(sessionId).use { session ->
|
||||
apkFile.inputStream().use { input ->
|
||||
session.openWrite("purefin-$versionCode.apk", 0, apkFile.length()).use { output ->
|
||||
input.copyTo(output)
|
||||
session.fsync(output)
|
||||
}
|
||||
}
|
||||
session.commit(statusReceiver(sessionId))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
installer.abandonSession(sessionId)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusReceiver(sessionId: Int): IntentSender {
|
||||
val intent = Intent(appContext, AppUpdateInstallReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
appContext,
|
||||
sessionId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
return pendingIntent.intentSender
|
||||
}
|
||||
|
||||
private fun openInstallPermissionSettings() {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${appContext.packageName}")
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try {
|
||||
appContext.startActivity(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
appContext.startActivity(
|
||||
Intent(Settings.ACTION_SECURITY_SETTINGS)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun appLabel(): String =
|
||||
appContext.applicationInfo.loadLabel(appContext.packageManager).toString()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package hu.bbara.purefin.update
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.feature.update.AppUpdateInstaller
|
||||
import hu.bbara.purefin.feature.update.AppVersionProvider
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AppUpdateModule {
|
||||
|
||||
@Binds
|
||||
abstract fun bindAppUpdateInstaller(impl: AndroidAppUpdateInstaller): AppUpdateInstaller
|
||||
|
||||
@Binds
|
||||
abstract fun bindAppVersionProvider(impl: AndroidAppVersionProvider): AppVersionProvider
|
||||
}
|
||||
Reference in New Issue
Block a user