Add prefs & page for app updates

This commit is contained in:
Damontecres 2025-10-11 18:32:19 -04:00
parent 14de2442bd
commit cbc680da7d
No known key found for this signature in database
9 changed files with 652 additions and 359 deletions

View file

@ -332,29 +332,36 @@ sealed interface AppPreference<T> {
getter = { },
setter = { prefs, _ -> prefs },
)
// val AutoCheckForUpdates =
// AppSwitchPreference(
// title = R.string.check_for_updates,
// prefKey = R.string.pref_key_auto_check_updates,
// defaultValue = true,
// getter = { it.updatePreferences.checkForUpdates },
// setter = { prefs, value ->
// prefs.updateUpdatePreferences { checkForUpdates = value }
// },
// summaryOn = R.string.enabled,
// summaryOff = R.string.disabled,
// )
// val UpdateUrl =
// AppStringPreference(
// title = R.string.update_url,
// defaultValue = "",
// getter = { it.updatePreferences.updateUrl },
// setter = { prefs, value ->
// prefs.updateUpdatePreferences { updateUrl = value }
// },
// summary = R.string.update_url_summary,
// )
val Update =
AppClickablePreference(
title = R.string.check_for_updates,
getter = { },
setter = { prefs, _ -> prefs },
)
val AutoCheckForUpdates =
AppSwitchPreference(
title = R.string.auto_check_for_updates,
defaultValue = true,
getter = { it.autoCheckForUpdates },
setter = { prefs, value ->
prefs.update { autoCheckForUpdates = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val UpdateUrl =
AppStringPreference(
title = R.string.update_url,
defaultValue = "", // TODO
getter = { it.updateUrl },
setter = { prefs, value ->
prefs.update { updateUrl = value }
},
summary = R.string.update_url_summary,
)
val OssLicenseInfo =
AppDestinationPreference(
@ -465,7 +472,7 @@ val basicPreferences =
preferences =
listOf(
AppPreference.InstalledVersion,
AppPreference.OssLicenseInfo,
AppPreference.Update,
),
),
PreferenceGroup(
@ -494,6 +501,21 @@ val advancedPreferences =
AppPreference.PlaybackDebugInfo,
),
),
PreferenceGroup(
title = R.string.updates,
preferences =
listOf(
AppPreference.AutoCheckForUpdates,
AppPreference.UpdateUrl,
),
),
PreferenceGroup(
title = R.string.more,
preferences =
listOf(
AppPreference.OssLicenseInfo,
),
),
)
data class AppSwitchPreference(

View file

@ -17,6 +17,9 @@ class AppPreferencesSerializer
AppPreferences
.newBuilder()
.apply {
updateUrl = AppPreference.UpdateUrl.defaultValue
autoCheckForUpdates = AppPreference.AutoCheckForUpdates.defaultValue
playbackPreferences =
PlaybackPreferences
.newBuilder()

View file

@ -73,6 +73,9 @@ sealed class Destination(
constructor(item: BaseItem) : this(item.id, item.resumeMs ?: 0, item)
}
@Serializable
data object UpdateApp : Destination(true)
@Serializable
data object License : Destination(true)
}

View file

@ -17,6 +17,7 @@ import com.github.damontecres.dolphin.ui.main.HomePage
import com.github.damontecres.dolphin.ui.main.SearchPage
import com.github.damontecres.dolphin.ui.playback.PlaybackPage
import com.github.damontecres.dolphin.ui.preferences.PreferencesPage
import com.github.damontecres.dolphin.ui.setup.InstallUpdatePage
import com.github.damontecres.dolphin.ui.setup.SwitchServerContent
import com.github.damontecres.dolphin.ui.setup.SwitchUserContent
import org.jellyfin.sdk.model.api.BaseItemKind
@ -134,6 +135,8 @@ fun DestinationContent(
}
}
Destination.UpdateApp -> InstallUpdatePage(preferences, modifier)
Destination.License -> LicenseInfo(modifier)
Destination.Search ->

View file

@ -20,6 +20,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -45,22 +46,21 @@ import com.github.damontecres.dolphin.preferences.advancedPreferences
import com.github.damontecres.dolphin.preferences.basicPreferences
import com.github.damontecres.dolphin.preferences.uiPreferences
import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.playOnClickSound
import com.github.damontecres.dolphin.ui.playSoundOnFocus
import com.github.damontecres.dolphin.ui.setup.UpdateViewModel
import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.ExceptionHandler
import kotlinx.coroutines.launch
data class Release(
val version: String,
)
@Composable
fun PreferencesContent(
initialPreferences: AppPreferences,
preferenceScreenOption: PreferenceScreenOption,
modifier: Modifier = Modifier,
viewModel: PreferencesViewModel = hiltViewModel(),
updateVM: UpdateViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@ -74,19 +74,16 @@ fun PreferencesContent(
}
}
val movementSounds = true
val installedVersion =
remember { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
var updateVersion by remember { mutableStateOf<Release?>(null) }
val updateAvailable = false
// remember(updateVersion) { updateVersion?.version?.isGreaterThan(installedVersion) == true }
val release by updateVM.release.observeAsState(null)
LaunchedEffect(Unit) {
if (preferences.autoCheckForUpdates) {
updateVM.init(preferences.updateUrl)
}
}
// if (preferences.updatePreferences.checkForUpdates) {
// LaunchedEffect(Unit) {
// updateVersion =
// UpdateChecker.getLatestRelease(context, preferences.updatePreferences.updateUrl)
// }
// }
val movementSounds = true
val installedVersion = updateVM.currentVersion
val updateAvailable = release?.version?.isGreaterThan(installedVersion) ?: false
val prefList =
when (preferenceScreenOption) {
@ -135,6 +132,32 @@ fun PreferencesContent(
.padding(vertical = 8.dp),
)
}
if (preferenceScreenOption == PreferenceScreenOption.BASIC &&
preferences.autoCheckForUpdates &&
updateAvailable
) {
item {
val updateFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (focusedIndex.first == 0 && focusedIndex.second == 0) {
// Only re-focus if the user hasn't moved
updateFocusRequester.tryRequestFocus()
}
}
ClickPreference(
title = stringResource(R.string.install_update),
onClick = {
if (movementSounds) playOnClickSound(context)
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
},
summary = release?.version?.toString(),
modifier =
Modifier
.focusRequester(updateFocusRequester)
.playSoundOnFocus(movementSounds),
)
}
}
prefList.forEachIndexed { groupIndex, group ->
item {
Text(
@ -148,34 +171,6 @@ fun PreferencesContent(
.padding(top = 8.dp, bottom = 4.dp),
)
}
if (updateAvailable &&
groupIndex == 0 &&
preferenceScreenOption == PreferenceScreenOption.BASIC
) {
item {
val updateFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (focusedIndex.first == 0 && focusedIndex.second == 0) {
// Only re-focus if the user hasn't moved
updateFocusRequester.tryRequestFocus()
}
}
ClickPreference(
title = stringResource(R.string.install_update),
onClick = {
if (movementSounds) playOnClickSound(context)
updateVersion?.let {
// navigationManager.navigateTo(Destination.UpdateApp(it))
}
},
summary = updateVersion?.version?.toString(),
modifier =
Modifier
.focusRequester(updateFocusRequester)
.playSoundOnFocus(movementSounds),
)
}
}
group.preferences.forEachIndexed { prefIndex, pref ->
pref as AppPreference<Any>
item {
@ -196,7 +191,7 @@ fun PreferencesContent(
if (movementSounds) playOnClickSound(context)
if (clickCount++ >= 2) {
clickCount = 0
// navigationManager.navigateTo(Destination.Debug)
// navigationManager.navigateTo(Destination.Debug)
}
},
summary = installedVersion.toString(),
@ -210,57 +205,45 @@ fun PreferencesContent(
)
}
// AppPreference.Update -> {
// ClickPreference(
// title =
// if (updateVersion != null && updateAvailable) {
// stringResource(R.string.install_update)
// } else if (!preferences.updatePreferences.checkForUpdates && updateVersion == null) {
// stringResource(R.string.check_for_updates)
// } else {
// stringResource(R.string.no_update_available)
// },
// onClick = {
// if (movementSounds) playOnClickSound(context)
// if (updateVersion != null && updateAvailable) {
// updateVersion?.let {
// navigationManager.navigate(
// Destination.UpdateApp(it),
// )
// }
// } else {
// scope.launch {
// updateVersion =
// UpdateChecker.getLatestRelease(
// context,
// preferences.updatePreferences.updateUrl,
// )
// }
// }
// },
// onLongClick = {
// if (movementSounds) playOnClickSound(context)
// updateVersion?.let {
// navigationManager.navigate(
// Destination.UpdateApp(it),
// )
// }
// },
// summary =
// if (updateAvailable) {
// updateVersion?.version?.toString()
// } else {
// null
// },
// interactionSource = interactionSource,
// modifier =
// Modifier
// .ifElse(
// groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
// Modifier.focusRequester(focusRequester),
// ),
// )
// }
AppPreference.Update -> {
ClickPreference(
title =
if (release != null && updateAvailable) {
stringResource(R.string.install_update)
} else if (!preferences.autoCheckForUpdates && release == null) {
stringResource(R.string.check_for_updates)
} else {
stringResource(R.string.no_update_available)
},
onClick = {
if (movementSounds) playOnClickSound(context)
if (release != null && updateAvailable) {
release?.let {
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
}
} else {
updateVM.init(preferences.updateUrl)
}
},
onLongClick = {
if (movementSounds) playOnClickSound(context)
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
},
summary =
if (updateAvailable) {
release?.version?.toString()
} else {
null
},
interactionSource = interactionSource,
modifier =
Modifier
.ifElse(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
Modifier.focusRequester(focusRequester),
),
)
}
else -> {
val value = pref.getter.invoke(preferences)

View file

@ -0,0 +1,263 @@
package com.github.damontecres.dolphin.ui.setup
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.PreviewTvSpec
import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.ui.theme.DolphinTheme
import com.github.damontecres.dolphin.util.ExceptionHandler
import com.github.damontecres.dolphin.util.LoadingExceptionHandler
import com.github.damontecres.dolphin.util.LoadingState
import com.github.damontecres.dolphin.util.Release
import com.github.damontecres.dolphin.util.UpdateChecker
import com.github.damontecres.dolphin.util.Version
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class UpdateViewModel
@Inject
constructor(
val updater: UpdateChecker,
val navigationManager: NavigationManager,
) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val release = MutableLiveData<Release?>(null)
val currentVersion = updater.getInstalledVersion()
fun init(updateUrl: String) {
loading.value = LoadingState.Loading
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to check for update")) {
val release = updater.getLatestRelease(updateUrl)
withContext(Dispatchers.Main) {
this@UpdateViewModel.release.value = release
loading.value = LoadingState.Success
}
}
}
fun installRelease(release: Release) {
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to install update")) {
updater.installRelease(release)
}
}
}
@Composable
fun InstallUpdatePage(
preferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: UpdateViewModel = hiltViewModel(),
) {
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val release by viewModel.release.observeAsState(null)
LaunchedEffect(Unit) {
viewModel.init(preferences.appPreferences.updateUrl)
}
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state, modifier)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage(modifier)
LoadingState.Success ->
release?.let {
if (it.version.isGreaterThan(viewModel.currentVersion)) {
InstallUpdatePageContent(
currentVersion = viewModel.currentVersion,
release = it,
onInstallRelease = {
viewModel.installRelease(it)
},
onCancel = {
viewModel.navigationManager.goBack()
},
modifier = modifier,
)
} else {
Text(
text = "No update available",
)
}
}
}
}
@Composable
fun InstallUpdatePageContent(
currentVersion: Version,
release: Release,
onInstallRelease: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
val scrollAmount = 100f
val columnState = rememberLazyListState()
val scope = rememberCoroutineScope()
fun scroll(reverse: Boolean = false) {
scope.launch(ExceptionHandler()) {
columnState.scrollBy(if (reverse) -scrollAmount else scrollAmount)
}
}
val columnInteractionSource = remember { MutableInteractionSource() }
val columnFocused by columnInteractionSource.collectIsFocusedAsState()
val columnColor =
if (columnFocused) {
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
} else {
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
}
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.focusable(interactionSource = columnInteractionSource)
.fillMaxHeight()
.fillMaxWidth(.6f)
.background(
columnColor,
shape = RoundedCornerShape(16.dp),
).onKeyEvent {
if (it.type == KeyEventType.KeyUp) {
return@onKeyEvent false
}
if (it.key == Key.DirectionDown) {
scroll(false)
return@onKeyEvent true
}
if (it.key == Key.DirectionUp) {
scroll(true)
return@onKeyEvent true
}
return@onKeyEvent false
},
) {
item {
Text(
// TODO render markdown
text = release.notes.joinToString("\n\n") + (release.body ?: ""),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.CenterVertically)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), shape = RoundedCornerShape(16.dp))
.padding(16.dp),
) {
Text(
text = "Update available",
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "$currentVersion => " + release.version.toString(),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Button(
onClick = onInstallRelease,
) {
Text(
text = "Download & Update",
)
}
Button(
onClick = onCancel,
) {
Text(
text = "Cancel",
)
}
}
}
}
@PreviewTvSpec
@Composable
private fun InstallUpdatePageContentPreview() {
DolphinTheme {
InstallUpdatePageContent(
currentVersion = Version.fromString("v0.4.0"),
release =
Release(
version = Version.fromString("v0.5.3"),
downloadUrl = "https://url",
publishedAt = null,
body =
"Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " +
"ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " +
"tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " +
"Pulvinar vivamus fringilla lacus nec metus bibendum egestas. " +
"Iaculis massa nisl malesuada lacinia integer nunc posuere. " +
"Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora " +
"torquent per conubia nostra inceptos himenaeos.\n\n" +
"Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " +
"ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " +
"tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " +
"Pulvinar vivamus fringilla lacus nec metus bibendum egestas. " +
"Iaculis massa nisl malesuada lacinia integer nunc posuere. " +
"Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora " +
"torquent per conubia nostra inceptos himenaeos.",
notes = listOf(),
),
onInstallRelease = {},
onCancel = {},
)
}
}

View file

@ -16,8 +16,10 @@ import androidx.core.content.FileProvider
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.hilt.StandardOkHttpClient
import com.github.damontecres.dolphin.ui.findActivity
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@ -31,279 +33,284 @@ import okhttp3.Request
import timber.log.Timber
import java.io.File
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
class UpdateChecker(
private val context: Context,
private val okHttpClient: OkHttpClient,
) {
companion object {
// TODO apk names
private const val ASSET_NAME = "Dolphin.apk"
private const val DEBUG_ASSET_NAME = "Dolphin-debug.apk"
private const val RELEASE_ASSET_NAME = "Dolphin-release.apk"
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
private const val PERMISSION_REQUEST_CODE = 123456
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
}
suspend fun maybeShowUpdateToast(
updateUrl: String,
showNegativeToast: Boolean = false,
@Singleton
class UpdateChecker
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) {
val pref = PreferenceManager.getDefaultSharedPreferences(context)
val now = Date()
val lastUpdateCheckThreshold =
pref
.getLong(context.getString(R.string.pref_key_update_last_check_threshold), 12)
.hours
val lastUpdateCheck =
pref.getLong(
context.getString(R.string.pref_key_update_last_check),
0,
)
val timeSince = (now.time - lastUpdateCheck).milliseconds
Timber.v("Last successful update check was $timeSince ago")
val installedVersion = getInstalledVersion()
val latestRelease = getLatestRelease(updateUrl)
if (latestRelease != null && latestRelease.version.isGreaterThan(installedVersion)) {
Timber.v("Update available $installedVersion => ${latestRelease.version}")
pref.edit {
putLong(context.getString(R.string.pref_key_update_last_check), now.time)
}
if (lastUpdateCheckThreshold >= timeSince) {
Timber.i(
"Skipping update notification, threshold is $lastUpdateCheckThreshold",
companion object {
// TODO apk names
private const val ASSET_NAME = "Dolphin.apk"
private const val DEBUG_ASSET_NAME = "Dolphin-debug.apk"
private const val RELEASE_ASSET_NAME = "Dolphin-release.apk"
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
private const val PERMISSION_REQUEST_CODE = 123456
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
}
suspend fun maybeShowUpdateToast(
updateUrl: String,
showNegativeToast: Boolean = false,
) {
val pref = PreferenceManager.getDefaultSharedPreferences(context)
val now = Date()
val lastUpdateCheckThreshold =
pref
.getLong(context.getString(R.string.pref_key_update_last_check_threshold), 12)
.hours
val lastUpdateCheck =
pref.getLong(
context.getString(R.string.pref_key_update_last_check),
0,
)
val timeSince = (now.time - lastUpdateCheck).milliseconds
Timber.v("Last successful update check was $timeSince ago")
val installedVersion = getInstalledVersion()
val latestRelease = getLatestRelease(updateUrl)
if (latestRelease != null && latestRelease.version.isGreaterThan(installedVersion)) {
Timber.v("Update available $installedVersion => ${latestRelease.version}")
pref.edit {
putLong(context.getString(R.string.pref_key_update_last_check), now.time)
}
if (lastUpdateCheckThreshold >= timeSince) {
Timber.i(
"Skipping update notification, threshold is $lastUpdateCheckThreshold",
)
} else {
Toast
.makeText(
context,
"Update available: $installedVersion => ${latestRelease.version}!",
Toast.LENGTH_LONG,
).show()
}
} else {
Toast
.makeText(
context,
"Update available: $installedVersion => ${latestRelease.version}!",
Toast.LENGTH_LONG,
).show()
}
} else {
Timber.v("No update available for $installedVersion")
if (showNegativeToast) {
Toast
.makeText(
context,
"No updates available, $installedVersion is the latest!",
Toast.LENGTH_LONG,
).show()
Timber.v("No update available for $installedVersion")
if (showNegativeToast) {
Toast
.makeText(
context,
"No updates available, $installedVersion is the latest!",
Toast.LENGTH_LONG,
).show()
}
}
}
}
fun getInstalledVersion(): Version {
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
return Version.fromString(pkgInfo.versionName!!)
}
fun getInstalledVersion(): Version {
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
return Version.fromString(pkgInfo.versionName!!)
}
suspend fun getLatestRelease(updateUrl: String): Release? {
return withContext(Dispatchers.IO) {
val preferredAsset =
if (PreferenceManager
.getDefaultSharedPreferences(context)
.getBoolean("updatePreferRelease", true)
) {
RELEASE_ASSET_NAME
} else {
DEBUG_ASSET_NAME
}
val request =
Request
.Builder()
.url(updateUrl)
.get()
.build()
okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) {
val result = Json.parseToJsonElement(it.body!!.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val version = Version.tryFromString(name)
val publishedAt =
result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
val downloadUrl =
result.jsonObject["assets"]
?.jsonArray
?.firstOrNull { asset ->
val assetName =
asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull
assetName == ASSET_NAME || assetName == preferredAsset
}?.jsonObject
?.get("browser_download_url")
?.jsonPrimitive
?.contentOrNull
if (version != null) {
val notes =
if (body.isNotNullOrBlank()) {
NOTE_REGEX
.findAll(body)
.map { m ->
m.groupValues[1]
}.toList()
} else {
listOf()
}
return@use Release(version, downloadUrl, publishedAt, body, notes)
suspend fun getLatestRelease(updateUrl: String): Release? {
return withContext(Dispatchers.IO) {
val preferredAsset =
if (PreferenceManager
.getDefaultSharedPreferences(context)
.getBoolean("updatePreferRelease", true)
) {
RELEASE_ASSET_NAME
} else {
Timber.w("Update version parsing failed. name=$name")
DEBUG_ASSET_NAME
}
} else {
Timber.w("Update check failed: ${it.message}")
}
return@use null
}
}
}
suspend fun installRelease(release: Release) {
val activity = context.findActivity()!!
withContext(Dispatchers.IO) {
cleanup()
val request =
Request
.Builder()
.url(release.downloadUrl!!)
.get()
.build()
okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) {
Timber.v("Request successful for ${release.downloadUrl}")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, ASSET_NAME)
put(MediaStore.MediaColumns.MIME_TYPE, APK_MIME_TYPE)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_DOWNLOADS,
)
}
val resolver = context.contentResolver
val uri =
resolver.insert(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
contentValues,
)
if (uri != null) {
it.body!!.byteStream().use { input ->
resolver.openOutputStream(uri).use { output ->
input.copyTo(output!!)
val request =
Request
.Builder()
.url(updateUrl)
.get()
.build()
okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) {
val result = Json.parseToJsonElement(it.body!!.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val version = Version.tryFromString(name)
val publishedAt =
result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
val downloadUrl =
result.jsonObject["assets"]
?.jsonArray
?.firstOrNull { asset ->
val assetName =
asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull
assetName == ASSET_NAME || assetName == preferredAsset
}?.jsonObject
?.get("browser_download_url")
?.jsonPrimitive
?.contentOrNull
if (version != null) {
val notes =
if (body.isNotNullOrBlank()) {
NOTE_REGEX
.findAll(body)
.map { m ->
m.groupValues[1]
}.toList()
} else {
listOf()
}
}
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.data = uri
context.startActivity(intent)
return@use Release(version, downloadUrl, publishedAt, body, notes)
} else {
Timber.e("Resolver URI is null")
// TODO show error Toast
Timber.w("Update version parsing failed. name=$name")
}
} else {
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
) != PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_EXTERNAL_STORAGE,
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
activity,
arrayOf(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE,
),
PERMISSION_REQUEST_CODE,
)
} else {
val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
downloadDir.mkdirs()
val targetFile = File(downloadDir, ASSET_NAME)
targetFile.outputStream().use { output ->
it.body!!.byteStream().copyTo(output)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Timber.w("Update check failed: ${it.message}")
}
return@use null
}
}
}
suspend fun installRelease(release: Release) {
val activity = context.findActivity()!!
withContext(Dispatchers.IO) {
cleanup()
val request =
Request
.Builder()
.url(release.downloadUrl!!)
.get()
.build()
okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) {
Timber.v("Request successful for ${release.downloadUrl}")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, ASSET_NAME)
put(MediaStore.MediaColumns.MIME_TYPE, APK_MIME_TYPE)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_DOWNLOADS,
)
}
val resolver = context.contentResolver
val uri =
resolver.insert(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
contentValues,
)
if (uri != null) {
it.body!!.byteStream().use { input ->
resolver.openOutputStream(uri).use { output ->
input.copyTo(output!!)
}
}
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.data =
FileProvider.getUriForFile(
activity,
activity.packageName + ".provider",
targetFile,
)
activity.startActivity(intent)
intent.data = uri
context.startActivity(intent)
} else {
val intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(Uri.fromFile(targetFile), APK_MIME_TYPE)
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
activity.startActivity(intent)
Timber.e("Resolver URI is null")
// TODO show error Toast
}
} else {
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
) != PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_EXTERNAL_STORAGE,
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
activity,
arrayOf(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE,
),
PERMISSION_REQUEST_CODE,
)
} else {
val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
downloadDir.mkdirs()
val targetFile = File(downloadDir, ASSET_NAME)
targetFile.outputStream().use { output ->
it.body!!.byteStream().copyTo(output)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.data =
FileProvider.getUriForFile(
activity,
activity.packageName + ".provider",
targetFile,
)
activity.startActivity(intent)
} else {
val intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(Uri.fromFile(targetFile), APK_MIME_TYPE)
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
activity.startActivity(intent)
}
}
}
} else {
Timber.v("Request failed for ${release.downloadUrl}: ${it.code}")
// TODO show error toast
}
} else {
Timber.v("Request failed for ${release.downloadUrl}: ${it.code}")
// TODO show error toast
}
}
}
}
/**
* Delete previously downloaded APKs
*/
fun cleanup() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
context.contentResolver
.query(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.Files.FileColumns.DISPLAY_NAME,
),
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
null,
)?.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getString(0)
val displayName = cursor.getString(1)
Timber.v("id=$id, displayName=$displayName")
/**
* Delete previously downloaded APKs
*/
fun cleanup() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
context.contentResolver
.query(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.Files.FileColumns.DISPLAY_NAME,
),
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
null,
)?.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getString(0)
val displayName = cursor.getString(1)
Timber.v("id=$id, displayName=$displayName")
}
}
val deletedRows =
context.contentResolver.delete(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
)
Timber.i("Deleted $deletedRows rows")
} else {
val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val targetFile = File(downloadDir, ASSET_NAME)
if (targetFile.exists()) {
targetFile.delete()
}
val deletedRows =
context.contentResolver.delete(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
)
Timber.i("Deleted $deletedRows rows")
} else {
val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val targetFile = File(downloadDir, ASSET_NAME)
if (targetFile.exists()) {
targetFile.delete()
}
} catch (ex: Exception) {
Timber.e(ex, "Exception during cleanup")
}
} catch (ex: Exception) {
Timber.e(ex, "Exception during cleanup")
}
}
}
@Serializable
data class Release(

View file

@ -63,4 +63,7 @@ message AppPreferences {
PlaybackPreferences playback_preferences = 3;
HomePagePreferences home_page_preferences = 4;
InterfacePreferences interface_preferences = 5;
bool auto_check_for_updates = 6;
string update_url = 7;
}

View file

@ -68,5 +68,11 @@
<string name="skip_comercials_behavior">Skip commercials behavior</string>
<string name="skip_outro_behavior">Skip outro behavior</string>
<string name="skip_intro_behavior">Skip intro behavior</string>
<string name="check_for_updates">Check for updates</string>
<string name="auto_check_for_updates">Automatically check for updates</string>
<string name="update_url">Update URL</string>
<string name="update_url_summary">URL used to check for app updates</string>
<string name="updates">Updates</string>
<string name="no_update_available">No update available</string>
</resources>