Skip to content

Instantly share code, notes, and snippets.

@tadfisher
Created June 1, 2022 18:00
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save tadfisher/e1bd46bfcabf92f96278bf8d25da6d0e to your computer and use it in GitHub Desktop.
Save tadfisher/e1bd46bfcabf92f96278bf8d25da6d0e to your computer and use it in GitHub Desktop.
In-app updates for Jetpack Compose
package com.mercury.app.updater
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.common.IntentSenderForResultStarter
import com.google.android.play.core.install.model.ActivityResult as UpdateActivityResult
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.UpdateAvailability
import com.google.android.play.core.ktx.AppUpdateResult
import com.google.android.play.core.ktx.bytesDownloaded
import com.google.android.play.core.ktx.clientVersionStalenessDays
import com.google.android.play.core.ktx.isFlexibleUpdateAllowed
import com.google.android.play.core.ktx.isImmediateUpdateAllowed
import com.google.android.play.core.ktx.requestUpdateFlow
import com.google.android.play.core.ktx.totalBytesToDownload
import com.google.android.play.core.ktx.updatePriority
import com.mercury.app.settings.InMemorySettings
import com.mercury.app.settings.Settings
import com.mercury.core.util.breadcrumbOf
import com.mercury.core.util.emptyBreadcrumb
import com.mercury.core.util.log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.daysUntil
import kotlinx.datetime.todayAt
@Stable
class AndroidUpdaterState(
private val appUpdateManager: AppUpdateManager,
private val settings: Settings,
private val timeZone: TimeZone,
private val produceIntentLauncher:
(onResult: (ActivityResult) -> Unit) -> ActivityResultLauncher<IntentSenderRequest>,
private val coroutineScope: CoroutineScope,
) : UpdaterState {
override val update: Update
@Composable
get() = produceState<Update>(
initialValue = Update.NotAvailable,
key1 = updateKey
) {
log.info("UpdaterState: Requesting update flow", emptyBreadcrumb)
combine(
appUpdateManager.requestUpdateFlow().catch { error ->
log.info("UpdaterState: Error in update flow", error)
AppUpdateResult.NotAvailable
},
settings.app.updateDeclinedVersion.data,
settings.app.updateDeclinedDate.data,
::Triple
).collect { (appUpdate, declinedVersion, declinedDate) ->
logUpdateResult(appUpdate)
value = appUpdate.toUpdateStatus(
declinedVersion,
declinedDate,
timeZone,
onStartUpdate = { updateInfo, updateType ->
log.info(
"UpdaterState: Starting update",
breadcrumbOf(
"updateType" to describeUpdateType(updateType)
)
)
startUpdate(updateInfo, updateType)
},
onDeclineUpdate = { updateInfo ->
log.info("UpdaterState: Declined flexible update", emptyBreadcrumb)
declineUpdate(updateInfo)
},
onCompleteUpdate = { result ->
log.info("UpdaterState: Completing flexible update", emptyBreadcrumb)
result.completeUpdate()
}
)
}
}.value
// We can't call `startUpdateFlowForResult` more than once with the same `updateInfo`,
// so we maintain a key to restart the update flow.
private var updateKey: Int = 0
private fun startUpdate(updateInfo: AppUpdateInfo, updateType: Int) {
appUpdateManager.startUpdateFlowForResult(
updateInfo,
updateType,
produceIntentLauncher { result ->
handleUpdateFlowResult(updateInfo, result)
}.starter(),
0
)
}
private fun declineUpdate(updateInfo: AppUpdateInfo) {
// Don't store declined state for high-priority updates.
if (updateInfo.updatePriority >= 4) return
coroutineScope.launch {
settings.app.runCatching {
updateDeclinedVersion.write(updateInfo.availableVersionCode())
updateDeclinedDate.write(Clock.System.todayAt(timeZone))
}.onFailure { error ->
log.error("UpdaterState: Failed to save updateDeclined state", error)
}
}
}
private fun handleUpdateFlowResult(updateInfo: AppUpdateInfo, updateResult: ActivityResult) {
when (updateResult.resultCode) {
Activity.RESULT_OK -> {
log.info("UpdaterState: result OK", emptyBreadcrumb)
}
Activity.RESULT_CANCELED -> {
log.info("UpdaterState: result CANCELED", emptyBreadcrumb)
declineUpdate(updateInfo)
}
UpdateActivityResult.RESULT_IN_APP_UPDATE_FAILED -> {
log.info("UpdaterState: result FAILED", emptyBreadcrumb)
}
}
// Changing the key restarts the flow in `update`, creating a new subscription to
// AppUpdateManager.requestUpdateFlow() and causing readers of that state to recompose.
updateKey++
}
private fun logUpdateResult(appUpdate: AppUpdateResult) {
when (appUpdate) {
AppUpdateResult.NotAvailable -> log.info("UpdaterState: not available")
is AppUpdateResult.Available -> log.info(
"UpdaterState: available",
with(appUpdate.updateInfo) {
breadcrumbOf(
"versionCode" to availableVersionCode(),
"priority" to updatePriority,
"stalenessDays" to clientVersionStalenessDays
)
}
)
// Avoid spamming our logs.
is AppUpdateResult.InProgress -> {}
is AppUpdateResult.Downloaded -> {}
}
}
}
private fun describeUpdateType(updateType: Int): String = when (updateType) {
AppUpdateType.IMMEDIATE -> "IMMEDIATE"
AppUpdateType.FLEXIBLE -> "FLEXIBLE"
else -> "unknown"
}
internal fun shouldUpdateImmediately(updateInfo: AppUpdateInfo): Boolean =
BuildConfig.FORCE_UPDATE_TYPE == AppUpdateType.IMMEDIATE || with(updateInfo) {
updateInfo.updateAvailability() ==
UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS ||
(isImmediateUpdateAllowed && updatePriority >= 4)
}
internal fun shouldPromptToUpdate(
updateInfo: AppUpdateInfo,
declinedVersion: Int?,
declinedDate: LocalDate?,
timeZone: TimeZone
): Boolean {
if (BuildConfig.FORCE_UPDATE_TYPE == AppUpdateType.FLEXIBLE) return true
with(updateInfo) {
// No point in prompting if we're not allowed to update.
if (!isFlexibleUpdateAllowed) return false
val promptIntervalDays = when {
// For medium-priority updates, prompt once per day.
updatePriority >= 2 -> 1
// For low-priority updates, prompt once per week.
else -> 7
}
// To prompt for an optional update, the update must be at least
// `promptIntervalDays` old and the user declined this update at
// least `promptIntervalDays` ago (or has never declined).
if (clientVersionStalenessDays ?: 0 < promptIntervalDays) return false
if (declinedVersion == availableVersionCode() &&
declinedDate?.daysUntil(Clock.System.todayAt(timeZone)) ?: 0 < promptIntervalDays
) {
return false
}
return true
}
}
@Stable
internal data class RequiredUpdate(
val onStartUpdate: () -> Unit
) : Update.Required {
override fun startUpdate() = onStartUpdate()
}
@Stable
internal data class OptionalUpdate(
val onStartUpdate: () -> Unit,
val onDeclineUpdate: () -> Unit,
override val shouldPrompt: Boolean
) : Update.Optional {
override fun startUpdate() = onStartUpdate()
override fun declineUpdate() = onDeclineUpdate()
}
@Stable
internal data class DownloadedUpdate(
val result: AppUpdateResult.Downloaded,
val onCompleteUpdate: suspend (AppUpdateResult.Downloaded) -> Unit
) : Update.Downloaded {
override suspend fun completeUpdate() = onCompleteUpdate.invoke(result)
}
@Stable
internal data class InProgressUpdate(
val result: AppUpdateResult.InProgress
) : Update.InProgress {
override val progressPercent: Int
get() = with(result.installState) {
val downloaded = bytesDownloaded
val total = totalBytesToDownload
if (total > 0) (downloaded * 100 / total).toInt() else 0
}
}
private fun AppUpdateResult.toUpdateStatus(
declinedVersion: Int?,
declinedDate: LocalDate?,
timeZone: TimeZone,
onStartUpdate: (updateInfo: AppUpdateInfo, updateType: Int) -> Unit,
onDeclineUpdate: (AppUpdateInfo) -> Unit,
onCompleteUpdate: suspend (AppUpdateResult.Downloaded) -> Unit
): Update = when (this) {
AppUpdateResult.NotAvailable -> Update.NotAvailable
is AppUpdateResult.Available -> if (shouldUpdateImmediately(updateInfo)) {
RequiredUpdate(
onStartUpdate = { onStartUpdate(updateInfo, AppUpdateType.IMMEDIATE) }
)
} else {
OptionalUpdate(
onStartUpdate = { onStartUpdate(updateInfo, AppUpdateType.FLEXIBLE) },
onDeclineUpdate = { onDeclineUpdate(updateInfo) },
shouldPrompt = shouldPromptToUpdate(updateInfo, declinedVersion, declinedDate, timeZone)
)
}
is AppUpdateResult.Downloaded -> DownloadedUpdate(this, onCompleteUpdate)
is AppUpdateResult.InProgress -> InProgressUpdate(this)
}
fun ActivityResultLauncher<IntentSenderRequest>.starter(): IntentSenderForResultStarter =
IntentSenderForResultStarter { intent, _, fillInIntent, flagsMask, flagsValue, _, _ ->
launch(
IntentSenderRequest.Builder(intent)
.setFillInIntent(fillInIntent)
.setFlags(flagsValue, flagsMask)
.build()
)
}
@Composable
fun rememberAndroidUpdaterState(
appUpdateManager: AppUpdateManager = AppUpdateManagerFactory.create(LocalContext.current),
settings: Settings = InMemorySettings(),
coroutineScope: CoroutineScope = rememberCoroutineScope(),
timeZone: TimeZone = TimeZone.currentSystemDefault()
): AndroidUpdaterState {
// This is a little gross, but we remember the `onResult` callback as state so that we can
// update `intentLauncher` with the new value when AndroidUpdaterState asks us for a new
// intent launcher. Why Google has the `IntentSenderForResultStarter` abstraction, I have no
// idea.
var onResultState: (ActivityResult) -> Unit by remember { mutableStateOf({}) }
val intentLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartIntentSenderForResult(),
onResult = { onResultState(it) }
)
return remember {
AndroidUpdaterState(
appUpdateManager,
settings,
timeZone,
{ onResult ->
onResultState = onResult
intentLauncher
},
coroutineScope
)
}
}
@Composable
internal actual fun rememberPlatformUpdaterState(
coroutineScope: CoroutineScope,
settings: Settings,
timeZone: TimeZone
): UpdaterState = rememberAndroidUpdaterState(
coroutineScope = coroutineScope,
settings = settings,
timeZone = timeZone
)
package com.mercury.app.updater
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.MaterialTheme
import androidx.compose.material.SnackbarDuration
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.IdlingResource
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.android.play.core.appupdate.testing.FakeAppUpdateManager
import com.google.android.play.core.install.model.InstallErrorCode
import com.mercury.app.settings.InMemorySettings
import com.mercury.app.settings.Settings
import com.mercury.app.updater.resources.Strings
import com.mercury.app.updater.resources.button
import com.mercury.app.updater.resources.restartButton
import com.mercury.app.updater.resources.restartMessage
import com.mercury.app.updater.resources.updateButton
import com.mercury.app.updater.resources.updateMessage
import com.mercury.core.util.PrintLogger
import com.mercury.core.util.log
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.should
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeTypeOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestCoroutineScope
import kotlinx.datetime.TimeZone
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
private object CoroutineResource : IdlingResource {
var busy = false
override val isIdleNow: Boolean get() = !busy
override fun getDiagnosticMessageIfBusy(): String? =
if (busy) "CoroutineResource is busy" else null
}
@RunWith(AndroidJUnit4::class)
class AndroidUpdaterStateTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>().apply {
registerIdlingResource(CoroutineResource)
}
init {
log = PrintLogger
}
@Test
fun unavailableStatus() {
var status: Update? = null
composeTestRule.setContent {
val updater = rememberFakeUpdater()
status = updater.update
}
composeTestRule.runOnIdle {
status shouldBe Update.NotAvailable
}
}
@Test
fun optionalUpdateAccepted() {
var updater: Update? = null
val updateManager = FakeAppUpdateManager(composeTestRule.activity).apply {
setUpdateAvailable(20)
setUpdatePriority(0)
setClientVersionStalenessDays(10)
}
val settings = InMemorySettings()
val coroutineScope = TestCoroutineScope()
val snackbarHostState = SnackbarHostState()
lateinit var updaterState: UpdaterState
composeTestRule.setContent {
updaterState = rememberFakeUpdater(
fakeAppUpdateManager = updateManager,
settings = settings,
coroutineScope = coroutineScope
)
updater = updaterState.update
Updater(updaterState, snackbarHostState) {
Text("Content")
}
}
composeTestRule.runOnIdle {
updater.shouldBeTypeOf<OptionalUpdate>().shouldPrompt shouldBe true
snackbarHostState.currentSnackbarData.shouldNotBeNull().run {
message shouldBe Strings.Updater.updateMessage
actionLabel shouldBe Strings.Updater.updateButton
duration shouldBe SnackbarDuration.Indefinite
performAction()
}
}
composeTestRule.onNodeWithText("Content").assertIsDisplayed()
composeTestRule.runOnIdle {
updateManager.isConfirmationDialogVisible shouldBe true
updateManager.userAcceptsUpdate()
}
composeTestRule.runOnIdle {
updater.shouldBeTypeOf<InProgressUpdate>().run {
progressPercent shouldBe 0
}
}
updateManager.downloadStarts()
updateManager.setTotalBytesToDownload(1000)
updateManager.setBytesDownloaded(0)
composeTestRule.runOnIdle {
updater.shouldBeTypeOf<InProgressUpdate>().run {
progressPercent shouldBe 0
}
}
updateManager.setBytesDownloaded(500)
composeTestRule.runOnIdle {
updater.shouldBeTypeOf<InProgressUpdate>().run {
progressPercent shouldBe 50
}
}
updateManager.downloadCompletes()
composeTestRule.runOnIdle {
updater.shouldBeTypeOf<DownloadedUpdate>()
snackbarHostState.currentSnackbarData.shouldNotBeNull().run {
message shouldBe Strings.Updater.restartMessage
actionLabel shouldBe Strings.Updater.restartButton
duration shouldBe SnackbarDuration.Indefinite
}
}
}
@Test
fun optionalUpdateDeclined() {
var update: Update? = null
val updateManager = FakeAppUpdateManager(composeTestRule.activity).apply {
setUpdateAvailable(20)
setUpdatePriority(0)
setClientVersionStalenessDays(10)
}
val coroutineScope = TestCoroutineScope()
val snackbarHostState = SnackbarHostState()
lateinit var updaterState: UpdaterState
composeTestRule.setContent {
updaterState = rememberFakeUpdater(
fakeAppUpdateManager = updateManager,
coroutineScope = coroutineScope
)
update = updaterState.update
Updater(updaterState, snackbarHostState) {
Text("Content")
}
}
composeTestRule.runOnIdle {
update.shouldBeTypeOf<OptionalUpdate>().shouldPrompt shouldBe true
snackbarHostState.currentSnackbarData.shouldNotBeNull().run {
should {
it.message shouldBe Strings.Updater.updateMessage
it.actionLabel shouldBe Strings.Updater.updateButton
it.duration shouldBe SnackbarDuration.Indefinite
}
dismiss()
}
}
composeTestRule.onNodeWithText("Content").assertIsDisplayed()
composeTestRule.runOnIdle {
update.shouldBeTypeOf<OptionalUpdate>().shouldPrompt shouldBe false
updateManager.userRejectsUpdate()
updateManager.isConfirmationDialogVisible shouldBe false
}
}
@Test
fun requiredUpdate() {
var update: Update? = null
val updateManager = FakeAppUpdateManager(composeTestRule.activity).apply {
setUpdateAvailable(20)
setUpdatePriority(5)
}
val coroutineScope = TestCoroutineScope()
val snackbarHostState = SnackbarHostState()
lateinit var updaterState: UpdaterState
composeTestRule.setContent {
updaterState = rememberFakeUpdater(
fakeAppUpdateManager = updateManager,
coroutineScope = coroutineScope
)
update = updaterState.update
MaterialTheme {
Updater(updaterState, snackbarHostState) {
Text("Content")
}
}
}
composeTestRule.runOnIdle {
update.shouldBeTypeOf<RequiredUpdate>()
updateManager.isImmediateFlowVisible shouldBe false
}
composeTestRule.onNodeWithText("Content").assertDoesNotExist()
composeTestRule.onNodeWithTag("UpdateRequired")
.assertIsDisplayed()
.onChildren()
.filterToOne(hasText(Strings.Updater.Required.button))
.performClick()
composeTestRule.runOnIdle {
updateManager.isImmediateFlowVisible shouldBe true
updateManager.userRejectsUpdate()
}
composeTestRule.onNodeWithText("Content").assertDoesNotExist()
composeTestRule.onNodeWithTag("UpdateRequired")
.assertIsDisplayed()
.onChildren()
.filterToOne(hasText(Strings.Updater.Required.button))
.performClick()
composeTestRule.runOnIdle {
updateManager.isImmediateFlowVisible shouldBe true
}
}
@Test
fun errorInUpdateFlow() {
var update: Update? = null
val updateManager = FakeAppUpdateManager(composeTestRule.activity).apply {
setInstallErrorCode(InstallErrorCode.ERROR_APP_NOT_OWNED)
}
val coroutineScope = TestCoroutineScope()
val snackbarHostState = SnackbarHostState()
lateinit var updaterState: UpdaterState
composeTestRule.setContent {
updaterState = rememberFakeUpdater(
fakeAppUpdateManager = updateManager,
coroutineScope = coroutineScope
)
update = updaterState.update
Updater(updaterState, snackbarHostState) {
Text("Content")
}
}
composeTestRule.runOnIdle {
update.shouldBeTypeOf<Update.NotAvailable>()
}
}
}
@Composable
private fun rememberFakeUpdater(
fakeAppUpdateManager: FakeAppUpdateManager = FakeAppUpdateManager(LocalContext.current),
settings: Settings = InMemorySettings(),
coroutineScope: CoroutineScope = rememberCoroutineScope()
): UpdaterState {
var onResultState: (ActivityResult) -> Unit by remember { mutableStateOf({}) }
val intentLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartIntentSenderForResult(),
onResult = { onResultState(it) }
)
return remember {
AndroidUpdaterState(
fakeAppUpdateManager,
settings,
TimeZone.UTC,
{ onResult ->
onResultState = onResult
intentLauncher
},
coroutineScope
)
}
}
package com.mercury.app.updater
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.MaterialTheme
import androidx.compose.material.SnackbarDuration
import androidx.compose.material.SnackbarHost
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import com.mercury.app.updater.resources.Strings
import com.mercury.app.updater.resources.button
import com.mercury.app.updater.resources.downloading
import com.mercury.app.updater.resources.message
import com.mercury.app.updater.resources.restartButton
import com.mercury.app.updater.resources.restartMessage
import com.mercury.app.updater.resources.title
import com.mercury.app.updater.resources.updateButton
import com.mercury.app.updater.resources.updateMessage
import com.mercury.ui.design.BaselineText
import com.mercury.ui.design.FilledButton
import com.mercury.ui.design.ScrollableColumn
import com.mercury.ui.design.SingleLine
import com.mercury.ui.design.plus
import com.mercury.ui.design.systemBarsPadding
/**
* Present a UI to display available app updates for the user to act upon.
*
* If an update requires immediate installation, [updateRequiredContent] is displayed and passed
* an [Update.Required] instance. [Update.Required.startUpdate] can then be used to launch the
* external update flow. The default implementation of [updateRequiredContent] presents a generic
* message along with a button to start the update flow.
*
* In all other cases, [content] is displayed. If an update is optional, [snackbarHostState] will
* be notified with an action to launch or install the update. The [snackbarHostState] should be
* passed to a [SnackbarHost] within [content] to ensure the snackbar is placed correctly in the UI.
*
* @param updaterState State interacting with the external update manager.
* @param snackbarHostState Snackbar state used to notify users of an optional update.
* @param updateRequiredContent Content to display when a required update is available.
* @param content Content to display when a required update is not available.
*/
@Composable
fun Updater(
updaterState: UpdaterState,
snackbarHostState: SnackbarHostState,
updateRequiredContent: @Composable (Update.Required) -> Unit = { update ->
DefaultUpdateRequired(update)
},
content: @Composable () -> Unit
) {
val update = updaterState.update
LaunchedEffect(update::class) {
when (update) {
Update.NotAvailable -> {}
is Update.Required -> {}
is Update.Optional -> {
if (update.shouldPrompt) {
val result = snackbarHostState.showSnackbar(
message = Strings.Updater.updateMessage,
actionLabel = Strings.Updater.updateButton,
duration = SnackbarDuration.Indefinite
)
when (result) {
SnackbarResult.Dismissed -> update.declineUpdate()
SnackbarResult.ActionPerformed -> update.startUpdate()
}
}
}
is Update.InProgress -> {
snackbarHostState.showSnackbar(message = Strings.Updater.downloading)
}
is Update.Downloaded -> {
val result = snackbarHostState.showSnackbar(
message = Strings.Updater.restartMessage,
actionLabel = Strings.Updater.restartButton,
duration = SnackbarDuration.Indefinite
)
if (result == SnackbarResult.ActionPerformed) {
update.completeUpdate()
}
}
}
}
if (update is Update.Required) {
updateRequiredContent(update)
} else {
content()
}
}
@Composable
fun DefaultUpdateRequired(update: Update.Required) {
UpdateRequired(
Modifier.fillMaxSize().systemBarsPadding(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 24.dp),
onStartUpdate = update::startUpdate
)
}
@Composable
fun UpdateRequired(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(),
onStartUpdate: () -> Unit = {},
) {
ScrollableColumn(
modifier.testTag("UpdateRequired"),
contentPadding = contentPadding.plus(horizontal = 16.dp, vertical = 24.dp)
) {
BaselineText(
text = Strings.Updater.Required.title,
style = MaterialTheme.typography.h6
)
Spacer(Modifier.height(8.dp))
BaselineText(Strings.Updater.Required.message)
Spacer(modifier = Modifier.weight(1f))
FilledButton(
onClick = onStartUpdate,
modifier = Modifier.fillMaxWidth()
) {
SingleLine(Strings.Updater.Required.button)
}
}
}
package com.mercury.app.updater
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@Preview
@Composable
fun UpdateRequiredPreview() {
MaterialTheme(
content = {
UpdateRequired(Modifier.fillMaxSize())
}
)
}
package com.mercury.app.updater
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.rememberCoroutineScope
import com.mercury.app.settings.InMemorySettings
import com.mercury.app.settings.Settings
import kotlinx.coroutines.CoroutineScope
import kotlinx.datetime.TimeZone
interface UpdaterState {
val update: Update
@Composable get
}
sealed interface Update {
object NotAvailable : Update
interface Required : Update {
fun startUpdate()
}
interface Optional : Update {
val shouldPrompt: Boolean
fun startUpdate()
fun declineUpdate()
}
interface InProgress : Update {
val progressPercent: Int
}
interface Downloaded : Update {
suspend fun completeUpdate()
}
}
@Immutable
object NoopUpdaterState : UpdaterState {
override val update: Update
@Composable
get() = Update.NotAvailable
}
@Composable
internal expect fun rememberPlatformUpdaterState(
coroutineScope: CoroutineScope,
settings: Settings,
timeZone: TimeZone
): UpdaterState
@Composable
fun rememberUpdaterState(
coroutineScope: CoroutineScope = rememberCoroutineScope(),
settings: Settings = InMemorySettings(),
timeZone: TimeZone = TimeZone.currentSystemDefault()
): UpdaterState = rememberPlatformUpdaterState(coroutineScope, settings, timeZone)
@ilhomsoliev
Copy link

yooo what?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment