Skip to content

Instantly share code, notes, and snippets.

@inidamleader
Last active December 5, 2024 12:52
Show Gist options
  • Save inidamleader/b6a76b3c503e1fff4400603e9a175d33 to your computer and use it in GitHub Desktop.
Save inidamleader/b6a76b3c503e1fff4400603e9a175d33 to your computer and use it in GitHub Desktop.
In-app update composable function implementation
package com.inidamleader.ovtracker.util.compose.update
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.LocalContext
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.appupdate.testing.FakeAppUpdateManager
import com.google.android.play.core.install.InstallState
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.InstallStatus
import com.google.android.play.core.install.model.UpdateAvailability
import com.inidamleader.ovtracker.global.AppRemoteConfig
import com.inidamleader.ovtracker.util.Holder
import com.inidamleader.ovtracker.util.getValue
import com.inidamleader.ovtracker.util.isGooglePlayServicesAvailable
import com.inidamleader.ovtracker.util.setValue
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.tasks.await
@Composable
fun InAppUpdater(
daysForUpdate: () -> AppRemoteConfig.DaysForUpdate,
completeUpdate: () -> Boolean,
completeUpdateOnStop: () -> Boolean,
onInstallStateChange: (InstallState) -> Unit,
onIsDownloadedChange: (Boolean) -> Unit,
testMode: Boolean = false,
testUpdateType: UpdateType = UpdateType.FLEXIBLE,
) {
val context = LocalContext.current
if (!context.isGooglePlayServicesAvailable && !testMode) return
val appUpdateManager = remember(key1 = testMode) {
if (testMode) FakeAppUpdateManager(context).apply {
setUpdateAvailable(Int.MAX_VALUE)
setClientVersionStalenessDays(Int.MAX_VALUE)
}
else AppUpdateManagerFactory.create(context)
}
var updateType by remember { mutableStateOf<UpdateType?>(null) }
var appUpdateInfo by remember { Holder<AppUpdateInfo?>(null) }
LaunchedEffect(key1 = Unit) {
snapshotFlow(daysForUpdate).collectLatest { daysForUpdate ->
if (daysForUpdate != AppRemoteConfig.DaysForUpdate(-1, -1))
try {
updateType = updateType(
daysForUpdate = daysForUpdate,
appUpdateInfo = appUpdateManager.appUpdateInfo.await()
.also {
appUpdateInfo = it
},
testMode = testMode,
testUpdateType = testUpdateType
)
} catch (_: Exception) {
// Getting appUpdateInfo failed
}
}
}
when (updateType) {
UpdateType.IMMEDIATE -> appUpdateInfo?.let {
HandleImmediateUpdate(
appUpdateManager = appUpdateManager,
appUpdateInfo = it,
testMode = testMode,
)
}
UpdateType.FLEXIBLE -> appUpdateInfo?.let {
HandleFlexibleUpdate(
appUpdateManager = appUpdateManager,
appUpdateInfo = it,
completeUpdate = completeUpdate,
completeUpdateOnStop = completeUpdateOnStop,
onInstallStateChange = onInstallStateChange,
onIsDownloadedChange = onIsDownloadedChange,
testMode = testMode,
)
}
null -> {}
}
}
private fun updateType(
daysForUpdate: AppRemoteConfig.DaysForUpdate,
appUpdateInfo: AppUpdateInfo,
testMode: Boolean,
testUpdateType: UpdateType,
) =
if (testMode) testUpdateType
else {
val clientVersionStalenessDays = appUpdateInfo.clientVersionStalenessDays()
val isUpdateAvailable =
appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE
val flexible = {
appUpdateInfo.installStatus() == InstallStatus.DOWNLOADED ||
(isUpdateAvailable &&
appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) &&
clientVersionStalenessDays != null &&
clientVersionStalenessDays >= daysForUpdate.flexible)
}
val immediate = {
appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS ||
(isUpdateAvailable &&
appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) &&
clientVersionStalenessDays != null &&
clientVersionStalenessDays >= daysForUpdate.immediate)
}
when {
daysForUpdate.immediate >= 0 && daysForUpdate.flexible < 0 && immediate() ->
UpdateType.IMMEDIATE
daysForUpdate.immediate < 0 && daysForUpdate.flexible >= 0 && flexible() ->
UpdateType.FLEXIBLE
daysForUpdate.immediate >= 0 && daysForUpdate.flexible >= 0 ->
when {
immediate() -> UpdateType.IMMEDIATE
flexible() -> UpdateType.FLEXIBLE
else -> null
}
else -> null
}
}
const val TAG = "InAppUpdater"
package com.inidamleader.ovtracker.util.compose.update
sealed interface ProgressState {
data object Inactive : ProgressState
data object PendingOrInstalling : ProgressState
data class Downloading(val progress: Float) : ProgressState
}
data class DaysForUpdate(val immediate: Long, val flexible: Long)
package com.inidamleader.ovtracker.util.compose.update
import com.google.android.play.core.install.InstallState
import com.google.android.play.core.install.model.InstallErrorCode
import com.google.android.play.core.install.model.InstallStatus
import kotlinx.coroutines.delay
import kotlin.math.pow
suspend fun progressUiTest(
interval: Long = 1000,
delay: Long = 2000,
downloadedTime: Long = 5000,
onInstallStateChange: (InstallState) -> Unit,
) {
delay(delay)
// test 0 100 value
onInstallStateChange(
InstallState.zza(
InstallStatus.DOWNLOADING,
0,
interval,
InstallErrorCode.NO_ERROR,
"",
)
)
delay(interval)
// test 100 0 value
onInstallStateChange(
InstallState.zza(
InstallStatus.DOWNLOADING,
interval,
0,
InstallErrorCode.NO_ERROR,
"",
)
)
delay(interval)
// test 0 0 value
onInstallStateChange(
InstallState.zza(
InstallStatus.DOWNLOADING,
0,
0,
InstallErrorCode.NO_ERROR,
"",
)
)
delay(interval)
val times = 10
repeat(times) {
onInstallStateChange(
InstallState.zza(
InstallStatus.DOWNLOADING,
times * (it + 1).toLong(),
times.toFloat().pow(2).toLong(),
InstallErrorCode.NO_ERROR,
"",
)
)
delay(interval / times)
}
onInstallStateChange(
InstallState.zza(
InstallStatus.DOWNLOADED,
0,
0,
InstallErrorCode.NO_ERROR,
"",
)
)
delay(downloadedTime)
onInstallStateChange(
InstallState.zza(
InstallStatus.INSTALLING,
0,
0,
InstallErrorCode.NO_ERROR,
"",
)
)
delay(interval)
onInstallStateChange(
InstallState.zza(
InstallStatus.INSTALLED,
0,
0,
InstallErrorCode.NO_ERROR,
"",
)
)
}
package com.inidamleader.ovtracker.util.compose.update
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@Composable
fun UpdaterProgressIndicator(
progressState: () -> ProgressState,
onPendingOrInstallingContent: @Composable () -> Unit,
onDownloadingContent: @Composable (() -> Float) -> Unit,
) {
when (val currentProgressState = progressState()) {
is ProgressState.PendingOrInstalling -> onPendingOrInstallingContent()
is ProgressState.Downloading -> {
val animatedProgress by animateFloatAsState(
targetValue = currentProgressState.progress,
label = "animatedProgress"
)
onDownloadingContent { animatedProgress }
}
ProgressState.Inactive -> {}
}
}
package com.inidamleader.ovtracker.util.compose.update
enum class UpdateType {
FLEXIBLE,
IMMEDIATE,
}
val Context.isGooglePlayServicesAvailable
get() = GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
package com.inidamleader.ovtracker.util.compose.update
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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.runtime.snapshotFlow
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.LifecycleStartEffect
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateOptions
import com.google.android.play.core.install.InstallState
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.InstallStatus
import com.google.android.play.core.ktx.installStatus
import com.inidamleader.ovtracker.util.Holder
import com.inidamleader.ovtracker.util.compose.ImmutableWrapper
import com.inidamleader.ovtracker.util.compose.getValue
import com.inidamleader.ovtracker.util.getValue
import com.inidamleader.ovtracker.util.setValue
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
@Composable
fun HandleFlexibleUpdate(
appUpdateManager: ImmutableWrapper<AppUpdateManager>,
appUpdateInfo: ImmutableWrapper<AppUpdateInfo>,
completeUpdate: () -> Boolean,
completeUpdateOnStop: () -> Boolean,
onInstallStateChange: (InstallState) -> Unit,
onIsDownloadedChange: (isDownloaded: Boolean) -> Unit,
testMode: Boolean,
) {
val activityResultLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartIntentSenderForResult(),
onResult = {}
)
val currentAppUpdateManager by appUpdateManager
val currentAppUpdateInfo by appUpdateInfo
var isDownloaded by remember { mutableStateOf(false) }
LaunchedEffect(key1 = Unit) {
snapshotFlow { isDownloaded }.collectLatest {
onIsDownloadedChange(it)
}
}
LaunchedEffect(key1 = Unit) {
snapshotFlow(completeUpdate).collectLatest {
if (it) currentAppUpdateManager.completeUpdate()
}
}
// OnCreate
DisposableEffect(key1 = Unit) {
val installStateUpdatedListener: (InstallState) -> Unit = { installState ->
if (testMode) Log.d(TAG, "InAppUpdater: installStatus = ${installState.installStatus}")
onInstallStateChange(installState)
isDownloaded = installState.installStatus == InstallStatus.DOWNLOADED
}
currentAppUpdateManager.registerListener(installStateUpdatedListener)
if (currentAppUpdateInfo.installStatus() == InstallStatus.DOWNLOADED)
isDownloaded = true
else
currentAppUpdateManager.startUpdateFlowForResult(
currentAppUpdateInfo,
activityResultLauncher,
AppUpdateOptions.defaultOptions(AppUpdateType.FLEXIBLE),
)
if (testMode) Log.d(TAG, "HandleFlexibleUpdate: Start FLEXIBLE Update Flow")
// OnDestroy
onDispose {
currentAppUpdateManager.unregisterListener(installStateUpdatedListener)
}
}
val coroutineScope = rememberCoroutineScope()
// OnStop: install update silently after 5s to be sure that is not a configuration change
var onStopJob by remember { Holder<Job?>(null) }
LifecycleStartEffect {
onStopJob?.cancel()
onStopOrDispose {
onStopJob = coroutineScope.launch {
delay(5000) // to be sure that is not a configuration change
if (completeUpdateOnStop()) {
try {
@Suppress("NAME_SHADOWING")
val appUpdateInfo = currentAppUpdateManager.appUpdateInfo.await()
if (appUpdateInfo.installStatus() == InstallStatus.DOWNLOADED)
currentAppUpdateManager.completeUpdate()
} catch (_: Exception) {
}
}
}
}
}
// Checks that the update is not stalled during 'onResume()'.
// However, you should execute this check at all app entry points.
LifecycleResumeEffect {
val onResumeJob = coroutineScope.launch {
try {
@Suppress("NAME_SHADOWING")
val appUpdateInfo = currentAppUpdateManager.appUpdateInfo.await()
if (appUpdateInfo.installStatus() == InstallStatus.DOWNLOADED)
// If the update is downloaded but not installed,
// notify the user to complete the update.
isDownloaded = true
} catch (_: Exception) {
}
}
onPauseOrDispose {
onResumeJob.cancel()
}
}
if (testMode)
LaunchedEffect(key1 = Unit) {
repeat(100) {
progressUiTest {
onInstallStateChange(it)
isDownloaded = it.installStatus() == InstallStatus.DOWNLOADED
}
}
}
}
package com.inidamleader.ovtracker.util.compose.update
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.runtime.snapshotFlow
import androidx.lifecycle.compose.LifecycleResumeEffect
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateOptions
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.UpdateAvailability
import com.inidamleader.ovtracker.util.Holder
import com.inidamleader.ovtracker.util.compose.ImmutableWrapper
import com.inidamleader.ovtracker.util.compose.getValue
import com.inidamleader.ovtracker.util.getValue
import com.inidamleader.ovtracker.util.setValue
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
@Composable
fun HandleImmediateUpdate(
appUpdateManager: ImmutableWrapper<AppUpdateManager>,
appUpdateInfo: ImmutableWrapper<AppUpdateInfo>,
testMode: Boolean,
) {
val activityResultLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartIntentSenderForResult(),
onResult = {}
)
val currentAppUpdateManager by appUpdateManager
var currentAppUpdateInfo by remember { Holder(appUpdateInfo.value) }
var restart by remember { mutableStateOf(Any()) }
LaunchedEffect(key1 = Unit) {
snapshotFlow { restart }.collectLatest {
currentAppUpdateManager.startUpdateFlowForResult(
currentAppUpdateInfo,
activityResultLauncher,
AppUpdateOptions.defaultOptions(AppUpdateType.IMMEDIATE),
)
if (testMode) Log.d(TAG, "HandleImmediateUpdate: Start IMMEDIATE Update Flow")
}
}
// Checks that the update is not stalled during 'onResume()'.
// However, you should execute this check at all entry points into the app.
val coroutineScope = rememberCoroutineScope()
LifecycleResumeEffect {
val job = coroutineScope.launch {
try {
currentAppUpdateInfo = currentAppUpdateManager.appUpdateInfo.await()
if (currentAppUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS)
// If an in-app update is already running, resume the update.
restart = Any()
} catch (_: Exception) {
}
}
onPauseOrDispose {
job.cancel()
}
}
}
package com.inidamleader.ovtracker.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import kotlin.reflect.KProperty
@Stable
data class Holder<T>(var value: T) {
companion object {
fun <T> getDefaultSaver() = listSaver(
save = { listOf(it.value) },
restore = { Holder<T>(it[0]) }
)
}
}
operator fun <T> Holder<T>.getValue(thisRef: Any?, property: KProperty<*>) = value
operator fun <T> Holder<T>.setValue(thisRef: Any?, property: KProperty<*>, value: T) {
this.value = value
}
@Composable
fun <T> rememberUpdatedHolder(newValue: T): Holder<T> = remember {
Holder(newValue)
}.apply { value = newValue }
Box {
val daysForUpdate by produceState(initialValue = AppRemoteConfig.DaysForUpdate(-1L, -1L)) {
delay(xxxxxx)
// value = AppRemoteConfig.DaysForUpdate(90L, 7L)
value = AppRemoteConfig.daysForUpdate
}
var showRestartToCompleteUpdateConfirmationDialog by remember { mutableStateOf(false) }
var completeUpdate by remember { mutableStateOf(false) }
var isDialogClosed by rememberSaveable(saver = Holder.getDefaultSaver()) {
Holder(false)
}
var progressState by remember { mutableStateOf<ProgressState>(ProgressState.Inactive) }
ComposeScope(show = { showRestartToCompleteUpdateConfirmationDialog && !isDialogClosed }) {
ConfirmationDialog(
title = R.string.relaunch_app_to_complete_update,
onConfirm = {
completeUpdate = true
showRestartToCompleteUpdateConfirmationDialog = false
isDialogClosed = true
},
onDismiss = {
completeUpdate = false
showRestartToCompleteUpdateConfirmationDialog = false
isDialogClosed = true
},
imageVector = Icons.Default.RestartAlt,
)
}
InAppUpdater(
daysForUpdate = { daysForUpdate },
completeUpdate = { completeUpdate },
completeUpdateOnStop = { true },
onInstallStateChange = { installState ->
progressState = when (installState.installStatus) {
InstallStatus.DOWNLOADING -> {
val totalBytes = installState.totalBytesToDownload().toFloat()
val bytesDownloaded = installState.bytesDownloaded().toFloat()
val progress = (bytesDownloaded / totalBytes).takeIf {
it.isFinite() && it in 0f..1f
}
progress?.let { ProgressState.Downloading(it) } ?: ProgressState.Inactive
}
InstallStatus.INSTALLING -> ProgressState.PendingOrInstalling
InstallStatus.PENDING -> ProgressState.PendingOrInstalling
InstallStatus.CANCELED -> ProgressState.Inactive
InstallStatus.DOWNLOADED -> ProgressState.Inactive
InstallStatus.FAILED -> ProgressState.Inactive
InstallStatus.INSTALLED -> ProgressState.Inactive
InstallStatus.REQUIRES_UI_INTENT -> ProgressState.Inactive
InstallStatus.UNKNOWN -> ProgressState.Inactive
else -> ProgressState.Inactive
}
},
onIsDownloadedChange = { isDownloaded ->
showRestartToCompleteUpdateConfirmationDialog = isDownloaded
},
)
UpdaterProgressIndicator(
progressState = { progressState },
onPendingOrInstallingContent = {
LinearProgressIndicator(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.zIndex(2F),
)
},
onDownloadingContent = {
LinearProgressIndicator(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.zIndex(2F),
progress = it,
)
},
)
}
package com.inidamleader.ovtracker.util.compose
import androidx.compose.runtime.Immutable
import kotlin.reflect.KProperty
@Immutable
data class ImmutableWrapper<T>(val value: T)
fun <T> T.toImmutableWrapper() = ImmutableWrapper(this)
operator fun <T> ImmutableWrapper<T>.getValue(thisRef: Any?, property: KProperty<*>) = value
@inidamleader
Copy link
Author

Could someone kindly review this code, please?

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