Skip to content

Instantly share code, notes, and snippets.

@inidamleader
Last active December 5, 2024 12:52
Show Gist options
  • Select an option

  • Save inidamleader/b6a76b3c503e1fff4400603e9a175d33 to your computer and use it in GitHub Desktop.

Select an option

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
Copy Markdown
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