Created
June 1, 2022 18:00
-
-
Save tadfisher/e1bd46bfcabf92f96278bf8d25da6d0e to your computer and use it in GitHub Desktop.
In-app updates for Jetpack Compose
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | |
} | |
) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
yooo what?