mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Add prefs & page for app updates
This commit is contained in:
parent
14de2442bd
commit
cbc680da7d
9 changed files with 652 additions and 359 deletions
|
|
@ -332,29 +332,36 @@ sealed interface AppPreference<T> {
|
||||||
getter = { },
|
getter = { },
|
||||||
setter = { prefs, _ -> prefs },
|
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 =
|
val Update =
|
||||||
// AppStringPreference(
|
AppClickablePreference(
|
||||||
// title = R.string.update_url,
|
title = R.string.check_for_updates,
|
||||||
// defaultValue = "",
|
getter = { },
|
||||||
// getter = { it.updatePreferences.updateUrl },
|
setter = { prefs, _ -> prefs },
|
||||||
// setter = { prefs, value ->
|
)
|
||||||
// prefs.updateUpdatePreferences { updateUrl = value }
|
|
||||||
// },
|
val AutoCheckForUpdates =
|
||||||
// summary = R.string.update_url_summary,
|
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 =
|
val OssLicenseInfo =
|
||||||
AppDestinationPreference(
|
AppDestinationPreference(
|
||||||
|
|
@ -465,7 +472,7 @@ val basicPreferences =
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.InstalledVersion,
|
AppPreference.InstalledVersion,
|
||||||
AppPreference.OssLicenseInfo,
|
AppPreference.Update,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PreferenceGroup(
|
PreferenceGroup(
|
||||||
|
|
@ -494,6 +501,21 @@ val advancedPreferences =
|
||||||
AppPreference.PlaybackDebugInfo,
|
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(
|
data class AppSwitchPreference(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ class AppPreferencesSerializer
|
||||||
AppPreferences
|
AppPreferences
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
.apply {
|
.apply {
|
||||||
|
updateUrl = AppPreference.UpdateUrl.defaultValue
|
||||||
|
autoCheckForUpdates = AppPreference.AutoCheckForUpdates.defaultValue
|
||||||
|
|
||||||
playbackPreferences =
|
playbackPreferences =
|
||||||
PlaybackPreferences
|
PlaybackPreferences
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,9 @@ sealed class Destination(
|
||||||
constructor(item: BaseItem) : this(item.id, item.resumeMs ?: 0, item)
|
constructor(item: BaseItem) : this(item.id, item.resumeMs ?: 0, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object UpdateApp : Destination(true)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object License : Destination(true)
|
data object License : Destination(true)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.main.SearchPage
|
||||||
import com.github.damontecres.dolphin.ui.playback.PlaybackPage
|
import com.github.damontecres.dolphin.ui.playback.PlaybackPage
|
||||||
import com.github.damontecres.dolphin.ui.preferences.PreferencesPage
|
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.SwitchServerContent
|
||||||
import com.github.damontecres.dolphin.ui.setup.SwitchUserContent
|
import com.github.damontecres.dolphin.ui.setup.SwitchUserContent
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
|
@ -134,6 +135,8 @@ fun DestinationContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Destination.UpdateApp -> InstallUpdatePage(preferences, modifier)
|
||||||
|
|
||||||
Destination.License -> LicenseInfo(modifier)
|
Destination.License -> LicenseInfo(modifier)
|
||||||
|
|
||||||
Destination.Search ->
|
Destination.Search ->
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.basicPreferences
|
||||||
import com.github.damontecres.dolphin.preferences.uiPreferences
|
import com.github.damontecres.dolphin.preferences.uiPreferences
|
||||||
import com.github.damontecres.dolphin.ui.ifElse
|
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.playOnClickSound
|
||||||
import com.github.damontecres.dolphin.ui.playSoundOnFocus
|
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.ui.tryRequestFocus
|
||||||
import com.github.damontecres.dolphin.util.ExceptionHandler
|
import com.github.damontecres.dolphin.util.ExceptionHandler
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class Release(
|
|
||||||
val version: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PreferencesContent(
|
fun PreferencesContent(
|
||||||
initialPreferences: AppPreferences,
|
initialPreferences: AppPreferences,
|
||||||
preferenceScreenOption: PreferenceScreenOption,
|
preferenceScreenOption: PreferenceScreenOption,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: PreferencesViewModel = hiltViewModel(),
|
viewModel: PreferencesViewModel = hiltViewModel(),
|
||||||
|
updateVM: UpdateViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
@ -74,19 +74,16 @@ fun PreferencesContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val movementSounds = true
|
val release by updateVM.release.observeAsState(null)
|
||||||
val installedVersion =
|
LaunchedEffect(Unit) {
|
||||||
remember { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
|
if (preferences.autoCheckForUpdates) {
|
||||||
var updateVersion by remember { mutableStateOf<Release?>(null) }
|
updateVM.init(preferences.updateUrl)
|
||||||
val updateAvailable = false
|
}
|
||||||
// remember(updateVersion) { updateVersion?.version?.isGreaterThan(installedVersion) == true }
|
}
|
||||||
|
|
||||||
// if (preferences.updatePreferences.checkForUpdates) {
|
val movementSounds = true
|
||||||
// LaunchedEffect(Unit) {
|
val installedVersion = updateVM.currentVersion
|
||||||
// updateVersion =
|
val updateAvailable = release?.version?.isGreaterThan(installedVersion) ?: false
|
||||||
// UpdateChecker.getLatestRelease(context, preferences.updatePreferences.updateUrl)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
val prefList =
|
val prefList =
|
||||||
when (preferenceScreenOption) {
|
when (preferenceScreenOption) {
|
||||||
|
|
@ -135,6 +132,32 @@ fun PreferencesContent(
|
||||||
.padding(vertical = 8.dp),
|
.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 ->
|
prefList.forEachIndexed { groupIndex, group ->
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -148,34 +171,6 @@ fun PreferencesContent(
|
||||||
.padding(top = 8.dp, bottom = 4.dp),
|
.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 ->
|
group.preferences.forEachIndexed { prefIndex, pref ->
|
||||||
pref as AppPreference<Any>
|
pref as AppPreference<Any>
|
||||||
item {
|
item {
|
||||||
|
|
@ -196,7 +191,7 @@ fun PreferencesContent(
|
||||||
if (movementSounds) playOnClickSound(context)
|
if (movementSounds) playOnClickSound(context)
|
||||||
if (clickCount++ >= 2) {
|
if (clickCount++ >= 2) {
|
||||||
clickCount = 0
|
clickCount = 0
|
||||||
// navigationManager.navigateTo(Destination.Debug)
|
// navigationManager.navigateTo(Destination.Debug)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
summary = installedVersion.toString(),
|
summary = installedVersion.toString(),
|
||||||
|
|
@ -210,57 +205,45 @@ fun PreferencesContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppPreference.Update -> {
|
AppPreference.Update -> {
|
||||||
// ClickPreference(
|
ClickPreference(
|
||||||
// title =
|
title =
|
||||||
// if (updateVersion != null && updateAvailable) {
|
if (release != null && updateAvailable) {
|
||||||
// stringResource(R.string.install_update)
|
stringResource(R.string.install_update)
|
||||||
// } else if (!preferences.updatePreferences.checkForUpdates && updateVersion == null) {
|
} else if (!preferences.autoCheckForUpdates && release == null) {
|
||||||
// stringResource(R.string.check_for_updates)
|
stringResource(R.string.check_for_updates)
|
||||||
// } else {
|
} else {
|
||||||
// stringResource(R.string.no_update_available)
|
stringResource(R.string.no_update_available)
|
||||||
// },
|
},
|
||||||
// onClick = {
|
onClick = {
|
||||||
// if (movementSounds) playOnClickSound(context)
|
if (movementSounds) playOnClickSound(context)
|
||||||
// if (updateVersion != null && updateAvailable) {
|
if (release != null && updateAvailable) {
|
||||||
// updateVersion?.let {
|
release?.let {
|
||||||
// navigationManager.navigate(
|
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
|
||||||
// Destination.UpdateApp(it),
|
}
|
||||||
// )
|
} else {
|
||||||
// }
|
updateVM.init(preferences.updateUrl)
|
||||||
// } else {
|
}
|
||||||
// scope.launch {
|
},
|
||||||
// updateVersion =
|
onLongClick = {
|
||||||
// UpdateChecker.getLatestRelease(
|
if (movementSounds) playOnClickSound(context)
|
||||||
// context,
|
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
|
||||||
// preferences.updatePreferences.updateUrl,
|
},
|
||||||
// )
|
summary =
|
||||||
// }
|
if (updateAvailable) {
|
||||||
// }
|
release?.version?.toString()
|
||||||
// },
|
} else {
|
||||||
// onLongClick = {
|
null
|
||||||
// if (movementSounds) playOnClickSound(context)
|
},
|
||||||
// updateVersion?.let {
|
interactionSource = interactionSource,
|
||||||
// navigationManager.navigate(
|
modifier =
|
||||||
// Destination.UpdateApp(it),
|
Modifier
|
||||||
// )
|
.ifElse(
|
||||||
// }
|
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
|
||||||
// },
|
Modifier.focusRequester(focusRequester),
|
||||||
// summary =
|
),
|
||||||
// if (updateAvailable) {
|
)
|
||||||
// updateVersion?.version?.toString()
|
}
|
||||||
// } else {
|
|
||||||
// null
|
|
||||||
// },
|
|
||||||
// interactionSource = interactionSource,
|
|
||||||
// modifier =
|
|
||||||
// Modifier
|
|
||||||
// .ifElse(
|
|
||||||
// groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
|
|
||||||
// Modifier.focusRequester(focusRequester),
|
|
||||||
// ),
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val value = pref.getter.invoke(preferences)
|
val value = pref.getter.invoke(preferences)
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,8 +16,10 @@ import androidx.core.content.FileProvider
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.github.damontecres.dolphin.R
|
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.findActivity
|
||||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
@ -31,279 +33,284 @@ import okhttp3.Request
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class UpdateChecker(
|
@Singleton
|
||||||
private val context: Context,
|
class UpdateChecker
|
||||||
private val okHttpClient: OkHttpClient,
|
@Inject
|
||||||
) {
|
constructor(
|
||||||
companion object {
|
@param:ApplicationContext private val context: Context,
|
||||||
// TODO apk names
|
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||||
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)
|
companion object {
|
||||||
val now = Date()
|
// TODO apk names
|
||||||
val lastUpdateCheckThreshold =
|
private const val ASSET_NAME = "Dolphin.apk"
|
||||||
pref
|
private const val DEBUG_ASSET_NAME = "Dolphin-debug.apk"
|
||||||
.getLong(context.getString(R.string.pref_key_update_last_check_threshold), 12)
|
private const val RELEASE_ASSET_NAME = "Dolphin-release.apk"
|
||||||
.hours
|
|
||||||
val lastUpdateCheck =
|
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
|
||||||
pref.getLong(
|
|
||||||
context.getString(R.string.pref_key_update_last_check),
|
private const val PERMISSION_REQUEST_CODE = 123456
|
||||||
0,
|
|
||||||
)
|
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
|
||||||
val timeSince = (now.time - lastUpdateCheck).milliseconds
|
}
|
||||||
Timber.v("Last successful update check was $timeSince ago")
|
|
||||||
val installedVersion = getInstalledVersion()
|
suspend fun maybeShowUpdateToast(
|
||||||
val latestRelease = getLatestRelease(updateUrl)
|
updateUrl: String,
|
||||||
if (latestRelease != null && latestRelease.version.isGreaterThan(installedVersion)) {
|
showNegativeToast: Boolean = false,
|
||||||
Timber.v("Update available $installedVersion => ${latestRelease.version}")
|
) {
|
||||||
pref.edit {
|
val pref = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
putLong(context.getString(R.string.pref_key_update_last_check), now.time)
|
val now = Date()
|
||||||
}
|
val lastUpdateCheckThreshold =
|
||||||
if (lastUpdateCheckThreshold >= timeSince) {
|
pref
|
||||||
Timber.i(
|
.getLong(context.getString(R.string.pref_key_update_last_check_threshold), 12)
|
||||||
"Skipping update notification, threshold is $lastUpdateCheckThreshold",
|
.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 {
|
} else {
|
||||||
Toast
|
Timber.v("No update available for $installedVersion")
|
||||||
.makeText(
|
if (showNegativeToast) {
|
||||||
context,
|
Toast
|
||||||
"Update available: $installedVersion => ${latestRelease.version}!",
|
.makeText(
|
||||||
Toast.LENGTH_LONG,
|
context,
|
||||||
).show()
|
"No updates available, $installedVersion is the latest!",
|
||||||
}
|
Toast.LENGTH_LONG,
|
||||||
} else {
|
).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 {
|
fun getInstalledVersion(): Version {
|
||||||
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
return Version.fromString(pkgInfo.versionName!!)
|
return Version.fromString(pkgInfo.versionName!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLatestRelease(updateUrl: String): Release? {
|
suspend fun getLatestRelease(updateUrl: String): Release? {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
val preferredAsset =
|
val preferredAsset =
|
||||||
if (PreferenceManager
|
if (PreferenceManager
|
||||||
.getDefaultSharedPreferences(context)
|
.getDefaultSharedPreferences(context)
|
||||||
.getBoolean("updatePreferRelease", true)
|
.getBoolean("updatePreferRelease", true)
|
||||||
) {
|
) {
|
||||||
RELEASE_ASSET_NAME
|
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)
|
|
||||||
} else {
|
} 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 request =
|
||||||
val activity = context.findActivity()!!
|
Request
|
||||||
withContext(Dispatchers.IO) {
|
.Builder()
|
||||||
cleanup()
|
.url(updateUrl)
|
||||||
val request =
|
.get()
|
||||||
Request
|
.build()
|
||||||
.Builder()
|
okHttpClient.newCall(request).execute().use {
|
||||||
.url(release.downloadUrl!!)
|
if (it.isSuccessful && it.body != null) {
|
||||||
.get()
|
val result = Json.parseToJsonElement(it.body!!.string())
|
||||||
.build()
|
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
|
||||||
okHttpClient.newCall(request).execute().use {
|
val version = Version.tryFromString(name)
|
||||||
if (it.isSuccessful && it.body != null) {
|
val publishedAt =
|
||||||
Timber.v("Request successful for ${release.downloadUrl}")
|
result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
|
||||||
val contentValues =
|
val downloadUrl =
|
||||||
ContentValues().apply {
|
result.jsonObject["assets"]
|
||||||
put(MediaStore.MediaColumns.DISPLAY_NAME, ASSET_NAME)
|
?.jsonArray
|
||||||
put(MediaStore.MediaColumns.MIME_TYPE, APK_MIME_TYPE)
|
?.firstOrNull { asset ->
|
||||||
put(
|
val assetName =
|
||||||
MediaStore.MediaColumns.RELATIVE_PATH,
|
asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull
|
||||||
Environment.DIRECTORY_DOWNLOADS,
|
assetName == ASSET_NAME || assetName == preferredAsset
|
||||||
)
|
}?.jsonObject
|
||||||
}
|
?.get("browser_download_url")
|
||||||
val resolver = context.contentResolver
|
?.jsonPrimitive
|
||||||
val uri =
|
?.contentOrNull
|
||||||
resolver.insert(
|
if (version != null) {
|
||||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
val notes =
|
||||||
contentValues,
|
if (body.isNotNullOrBlank()) {
|
||||||
)
|
NOTE_REGEX
|
||||||
if (uri != null) {
|
.findAll(body)
|
||||||
it.body!!.byteStream().use { input ->
|
.map { m ->
|
||||||
resolver.openOutputStream(uri).use { output ->
|
m.groupValues[1]
|
||||||
input.copyTo(output!!)
|
}.toList()
|
||||||
|
} else {
|
||||||
|
listOf()
|
||||||
}
|
}
|
||||||
}
|
return@use Release(version, downloadUrl, publishedAt, body, notes)
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
|
|
||||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
intent.data = uri
|
|
||||||
context.startActivity(intent)
|
|
||||||
} else {
|
} else {
|
||||||
Timber.e("Resolver URI is null")
|
Timber.w("Update version parsing failed. name=$name")
|
||||||
// TODO show error Toast
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (ContextCompat.checkSelfPermission(
|
Timber.w("Update check failed: ${it.message}")
|
||||||
context,
|
}
|
||||||
Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
return@use null
|
||||||
) != PackageManager.PERMISSION_GRANTED ||
|
}
|
||||||
ContextCompat.checkSelfPermission(
|
}
|
||||||
context,
|
}
|
||||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
|
||||||
) != PackageManager.PERMISSION_GRANTED
|
suspend fun installRelease(release: Release) {
|
||||||
) {
|
val activity = context.findActivity()!!
|
||||||
ActivityCompat.requestPermissions(
|
withContext(Dispatchers.IO) {
|
||||||
activity,
|
cleanup()
|
||||||
arrayOf(
|
val request =
|
||||||
Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
Request
|
||||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
.Builder()
|
||||||
),
|
.url(release.downloadUrl!!)
|
||||||
PERMISSION_REQUEST_CODE,
|
.get()
|
||||||
)
|
.build()
|
||||||
} else {
|
okHttpClient.newCall(request).execute().use {
|
||||||
val downloadDir =
|
if (it.isSuccessful && it.body != null) {
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
Timber.v("Request successful for ${release.downloadUrl}")
|
||||||
downloadDir.mkdirs()
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
val targetFile = File(downloadDir, ASSET_NAME)
|
val contentValues =
|
||||||
targetFile.outputStream().use { output ->
|
ContentValues().apply {
|
||||||
it.body!!.byteStream().copyTo(output)
|
put(MediaStore.MediaColumns.DISPLAY_NAME, ASSET_NAME)
|
||||||
}
|
put(MediaStore.MediaColumns.MIME_TYPE, APK_MIME_TYPE)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
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)
|
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
|
||||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
intent.data =
|
intent.data = uri
|
||||||
FileProvider.getUriForFile(
|
context.startActivity(intent)
|
||||||
activity,
|
|
||||||
activity.packageName + ".provider",
|
|
||||||
targetFile,
|
|
||||||
)
|
|
||||||
activity.startActivity(intent)
|
|
||||||
} else {
|
} else {
|
||||||
val intent = Intent(Intent.ACTION_VIEW)
|
Timber.e("Resolver URI is null")
|
||||||
intent.setDataAndType(Uri.fromFile(targetFile), APK_MIME_TYPE)
|
// TODO show error Toast
|
||||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
}
|
||||||
activity.startActivity(intent)
|
} 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
|
* Delete previously downloaded APKs
|
||||||
*/
|
*/
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
try {
|
try {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
context.contentResolver
|
context.contentResolver
|
||||||
.query(
|
.query(
|
||||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
||||||
arrayOf(
|
arrayOf(
|
||||||
MediaStore.MediaColumns._ID,
|
MediaStore.MediaColumns._ID,
|
||||||
MediaStore.Files.FileColumns.DISPLAY_NAME,
|
MediaStore.Files.FileColumns.DISPLAY_NAME,
|
||||||
),
|
),
|
||||||
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
|
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
|
||||||
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
|
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
|
||||||
null,
|
null,
|
||||||
)?.use { cursor ->
|
)?.use { cursor ->
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
val id = cursor.getString(0)
|
val id = cursor.getString(0)
|
||||||
val displayName = cursor.getString(1)
|
val displayName = cursor.getString(1)
|
||||||
Timber.v("id=$id, displayName=$displayName")
|
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
|
@Serializable
|
||||||
data class Release(
|
data class Release(
|
||||||
|
|
|
||||||
|
|
@ -63,4 +63,7 @@ message AppPreferences {
|
||||||
PlaybackPreferences playback_preferences = 3;
|
PlaybackPreferences playback_preferences = 3;
|
||||||
HomePagePreferences home_page_preferences = 4;
|
HomePagePreferences home_page_preferences = 4;
|
||||||
InterfacePreferences interface_preferences = 5;
|
InterfacePreferences interface_preferences = 5;
|
||||||
|
|
||||||
|
bool auto_check_for_updates = 6;
|
||||||
|
string update_url = 7;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,5 +68,11 @@
|
||||||
<string name="skip_comercials_behavior">Skip commercials behavior</string>
|
<string name="skip_comercials_behavior">Skip commercials behavior</string>
|
||||||
<string name="skip_outro_behavior">Skip outro behavior</string>
|
<string name="skip_outro_behavior">Skip outro behavior</string>
|
||||||
<string name="skip_intro_behavior">Skip intro 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>
|
</resources>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue