diff --git a/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt index 50667494..912e31d9 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt @@ -332,29 +332,36 @@ sealed interface AppPreference { 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( diff --git a/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreferencesSerializer.kt index 23b50f75..f48df5ac 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreferencesSerializer.kt @@ -17,6 +17,9 @@ class AppPreferencesSerializer AppPreferences .newBuilder() .apply { + updateUrl = AppPreference.UpdateUrl.defaultValue + autoCheckForUpdates = AppPreference.AutoCheckForUpdates.defaultValue + playbackPreferences = PlaybackPreferences .newBuilder() diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt index 6c1f2184..e384f8bf 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt @@ -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) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt index 063dff3d..9eaa1c36 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt @@ -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 -> diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferencesContent.kt index 8f48f51e..f7fa5bc7 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferencesContent.kt @@ -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(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 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) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/InstallUpdatePage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/InstallUpdatePage.kt new file mode 100644 index 00000000..8c0672bb --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/InstallUpdatePage.kt @@ -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.Pending) + val release = MutableLiveData(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 = {}, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/dolphin/util/UpdateChecker.kt index f0234568..acb97680 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/UpdateChecker.kt @@ -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("") - } - - 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("") + } + + 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( diff --git a/app/src/main/proto/DolphinDataStore.proto b/app/src/main/proto/DolphinDataStore.proto index bbb2c1a3..3b22208f 100644 --- a/app/src/main/proto/DolphinDataStore.proto +++ b/app/src/main/proto/DolphinDataStore.proto @@ -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; } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ca64344f..adfa8142 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -68,5 +68,11 @@ Skip commercials behavior Skip outro behavior Skip intro behavior + Check for updates + Automatically check for updates + Update URL + URL used to check for app updates + Updates + No update available