Compare commits

...

8 Commits

34 changed files with 1288 additions and 11 deletions

View File

@@ -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"

View File

@@ -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>

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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")
}
}
)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
)
}

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -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()
}
}

View File

@@ -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"

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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
)
}
)
}

View File

@@ -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) }
)
}

View File

@@ -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()
)
}
}

View File

@@ -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)
)
}

View 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()
)

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -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
)

View File

@@ -0,0 +1,5 @@
package hu.bbara.purefin.feature.update
interface AppUpdateInstaller {
suspend fun installUpdate(update: AppUpdateInfo): String
}

View File

@@ -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
)

View File

@@ -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
}
}

View File

@@ -0,0 +1,6 @@
package hu.bbara.purefin.feature.update
interface AppVersionProvider {
val versionCode: Long
val updateManifestUrl: String
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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()
)
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View 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()
}

View File

@@ -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
}