Skip to content

Commit

Permalink
In app update (#28)
Browse files Browse the repository at this point in the history
## Context
We need a fine control of the distributed app versions. 
- If a major bug is released, we may need to force users to update to a newer version.
- Currently the app does not have a domain to its APIs, we're using a CloudFront provided url. In the future I may change and I need everyone to update to the latest version.

## Code
- Implementing Play Store InApp Update

## Additional notes
![image](https://user-images.githubusercontent.com/38049362/109011124-58f84a80-768f-11eb-874a-9e587c4da641.png)
  • Loading branch information
cmorigaki authored Feb 24, 2021
1 parent f8bb719 commit 2810278
Show file tree
Hide file tree
Showing 36 changed files with 598 additions and 13 deletions.
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ dependencies {
implementation project(Features.recipeDetail)
implementation project(Features.settings)
implementation project(Features.settingsTheme)
implementation project(Features.inAppUpdate)

testImplementation TestConfig.jUnit
testImplementation TestConfig.mockk
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/br/com/recipebook/di/KoinInitializer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.app.Application
import br.com.recipebook.analytics.amplitude.di.amplitudeAnalyticsModule
import br.com.recipebook.coreandroid.di.coreAndroidModule
import br.com.recipebook.image.di.imageModule
import br.com.recipebook.inappupdate.di.inAppUpdateModules
import br.com.recipebook.monitoring.di.monitoringModule
import br.com.recipebook.navigation.di.navigationModule
import br.com.recipebook.recipecollection.di.recipeCollectionModules
Expand Down Expand Up @@ -31,6 +32,8 @@ object KoinInitializer {
settingsModules +
settingsThemeModules +
amplitudeAnalyticsModule +
configurationModules +
inAppUpdateModules +
monitoringModule
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ object BuildConfigurationProvider {
appInfo = AppInfo(
name = BuildConfig.APPLICATION_ID,
version = BuildConfig.VERSION_NAME,
versionCode = BuildConfig.VERSION_CODE,
variant = BuildConfig.BUILD_TYPE,
buildVariant = if (BuildConfig.DEBUG) BuildVariant.DEBUG else BuildVariant.RELEASE
),
Expand Down
11 changes: 11 additions & 0 deletions features/in-app-update/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
apply from: "$rootDir/project-config/feature-complete-build.gradle"
apply plugin: 'kotlin-parcelize'

dependencies {
implementation AndroidLibConfig.playCore
implementation AndroidLibConfig.playCoreExtensions

implementation project(ProjectConfig.configuration)
implementation project(ProjectConfig.monitoring)
implementation project(ProjectConfig.analytics)
}
3 changes: 3 additions & 0 deletions features/in-app-update/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="br.com.recipebook.inappupdate">
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package br.com.recipebook.inappupdate

import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import br.com.recipebook.utilityandroid.view.putSafeArgs
import br.com.recipebook.utilityandroid.view.safeArgs
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.install.model.UpdateAvailability
import com.google.android.play.core.ktx.startUpdateFlowForResult
import kotlinx.coroutines.CompletableDeferred

internal class InAppUpdateHeadlessFragment : Fragment() {
private val safeArgs by safeArgs<InAppUpdateSafeArgs>()

lateinit var completableDeferred: CompletableDeferred<InAppUpdateResult>

override fun onResume() {
super.onResume()
checkInAppUpdate()
}

private fun checkInAppUpdate() {
// Creates instance of the manager.
val appUpdateManager = AppUpdateManagerFactory.create(requireContext())

// Returns an intent object that you use to check for an update.
val appUpdateInfoTask = appUpdateManager.appUpdateInfo

// Checks that the platform will allow the specified type of update.
appUpdateInfoTask.addOnSuccessListener { appUpdateInfo ->
if (isUpdateAvailable(appUpdateInfo.updateAvailability()) &&
appUpdateInfo.isUpdateTypeAllowed(safeArgs.appUpdateType)
) {
// Request the update.
appUpdateManager.startUpdateFlowForResult(
appUpdateInfo,
safeArgs.appUpdateType,
this,
safeArgs.requestCode
)
} else {
completeAndRemoveFragment(InAppUpdateResult.UpdateNotAvailable)
}
}
}

private fun isUpdateAvailable(updateAvailability: Int): Boolean {
return updateAvailability == UpdateAvailability.UPDATE_AVAILABLE ||
updateAvailability == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS
}

override fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?
) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == safeArgs.requestCode) {
if (resultCode != AppCompatActivity.RESULT_OK) {
// If the update is cancelled or fails,
// you can request to start the update again.
completeAndRemoveFragment(InAppUpdateResult.UpdateFailed)
} else {
completeAndRemoveFragment(InAppUpdateResult.UpdateCompleted)
}
}
}

private fun completeAndRemoveFragment(result: InAppUpdateResult) {
if (::completableDeferred.isInitialized) {
completableDeferred.complete(result)
}
removeThisFragment()
}

private fun removeThisFragment() {
parentFragmentManager.beginTransaction()
.remove(this)
.commitAllowingStateLoss()
}

override fun onDestroy() {
super.onDestroy()
if (::completableDeferred.isInitialized && completableDeferred.isActive) {
completableDeferred.cancel()
}
}

companion object {
fun newInstance(
appUpdateType: Int,
requestCode: Int
) = InAppUpdateHeadlessFragment()
.putSafeArgs(
InAppUpdateSafeArgs(
appUpdateType = appUpdateType,
requestCode = requestCode,
)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package br.com.recipebook.inappupdate

import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.CompletableDeferred

private const val TAG = "InAppUpdateManager"

suspend fun FragmentActivity.requestInAppUpdate(
appUpdateType: Int,
requestCode: Int
): InAppUpdateResult {
val fragment = supportFragmentManager.findFragmentByTag(TAG) as? InAppUpdateHeadlessFragment
return if (fragment == null) {
val inAppUpdate = InAppUpdateHeadlessFragment.newInstance(
appUpdateType = appUpdateType,
requestCode = requestCode
).apply {
completableDeferred = CompletableDeferred()
}
supportFragmentManager.beginTransaction().add(inAppUpdate, TAG).commitAllowingStateLoss()
inAppUpdate.completableDeferred.await()
} else {
fragment.completableDeferred.await()
}
}

sealed class InAppUpdateResult {
object UpdateCompleted : InAppUpdateResult()
object UpdateNotAvailable : InAppUpdateResult()
object UpdateFailed : InAppUpdateResult()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package br.com.recipebook.inappupdate

import android.os.Parcelable
import kotlinx.android.parcel.Parcelize

@Parcelize
data class InAppUpdateSafeArgs(
val appUpdateType: Int,
val requestCode: Int,
) : Parcelable
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package br.com.recipebook.inappupdate.analytics

import br.com.recipebook.analytics.Event

data class InAppUpdateEvent(
private val shouldUpdate: Boolean,
private val updateStatus: String?,
private val currentVersionCode: Int,
) : Event {
override val id: String = "in_app_update"
override val properties: Map<String, Any?>
get() = mapOf(
"should_update" to shouldUpdate,
"update_status" to updateStatus,
"current_version_code" to currentVersionCode,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package br.com.recipebook.inappupdate.di

import br.com.recipebook.inappupdate.domain.CheckInAppUpdate
import br.com.recipebook.inappupdate.domain.CheckInAppUpdateUseCase
import br.com.recipebook.inappupdate.domain.InAppUpdater
import br.com.recipebook.inappupdate.view.InAppUpdaterImpl
import org.koin.dsl.module

val inAppUpdateDomainModule = module {
factory<CheckInAppUpdateUseCase> {
CheckInAppUpdate(
buildConfiguration = get(),
configurationRepository = get(),
inAppUpdater = get(),
analytics = get(),
)
}
}

val inAppUpdateViewModule = module {
factory<InAppUpdater> {
InAppUpdaterImpl(
activityProvider = get(),
)
}
}

val inAppUpdateModules = listOf(
inAppUpdateDomainModule,
inAppUpdateViewModule
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package br.com.recipebook.inappupdate.domain

import br.com.recipebook.analytics.Analytics
import br.com.recipebook.di.BuildConfiguration
import br.com.recipebook.domain.ConfigurationRepository
import br.com.recipebook.domain.model.AppUpdateInfoModel
import br.com.recipebook.inappupdate.InAppUpdateResult
import br.com.recipebook.inappupdate.analytics.InAppUpdateEvent
import br.com.recipebook.monitoring.crashreport.Breadcrumb
import br.com.recipebook.monitoring.crashreport.Monitoring
import br.com.recipebook.utilitykotlin.ResultWrapper

interface CheckInAppUpdateUseCase {
suspend operator fun invoke(): Boolean
}

internal class CheckInAppUpdate(
private val buildConfiguration: BuildConfiguration,
private val configurationRepository: ConfigurationRepository,
private val inAppUpdater: InAppUpdater,
private val analytics: Analytics,
) : CheckInAppUpdateUseCase {
override suspend fun invoke(): Boolean {
val shouldUpdate = when (val result = configurationRepository.getAppUpdateInfo()) {
is ResultWrapper.Success -> shouldAskUpdate(result.data)
is ResultWrapper.Failure -> true
}

return if (shouldUpdate) {
Monitoring.addBreadcrumb(Breadcrumb.StartingInAppUpdate)
val result = inAppUpdater()
sendEvent(shouldUpdate, result)
result !is InAppUpdateResult.UpdateFailed
} else {
sendEvent(shouldUpdate, null)
true
}
}

private fun shouldAskUpdate(info: AppUpdateInfoModel): Boolean {
return buildConfiguration.appInfo.versionCode.let { currentVersionCode ->
(currentVersionCode < info.minimumVersionCode ?: 0) ||
info.excludedVersionCodes.contains(currentVersionCode)
}
}

private fun sendEvent(
shouldUpdate: Boolean,
updateStatus: InAppUpdateResult?
) {
val updateResult = when (updateStatus) {
null -> null
is InAppUpdateResult.UpdateNotAvailable -> "Not available"
is InAppUpdateResult.UpdateCompleted -> "Completed"
is InAppUpdateResult.UpdateFailed -> "Failed"
}
analytics.sendEvent(
InAppUpdateEvent(
shouldUpdate = shouldUpdate,
updateStatus = updateResult,
currentVersionCode = buildConfiguration.appInfo.versionCode
)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package br.com.recipebook.inappupdate.domain

import br.com.recipebook.inappupdate.InAppUpdateResult

interface InAppUpdater {
suspend operator fun invoke(): InAppUpdateResult
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package br.com.recipebook.inappupdate.view

import androidx.fragment.app.FragmentActivity
import br.com.recipebook.inappupdate.InAppUpdateResult
import br.com.recipebook.inappupdate.domain.InAppUpdater
import br.com.recipebook.inappupdate.requestInAppUpdate
import br.com.recipebook.view.ActivityProvider
import com.google.android.play.core.install.model.AppUpdateType

private const val REQUEST_CODE = 1292

internal class InAppUpdaterImpl(
private val activityProvider: ActivityProvider,
) : InAppUpdater {
override suspend fun invoke(): InAppUpdateResult {
return (activityProvider.activeActivity as? FragmentActivity)?.requestInAppUpdate(
appUpdateType = AppUpdateType.IMMEDIATE,
requestCode = REQUEST_CODE
) ?: InAppUpdateResult.UpdateFailed
}
}
1 change: 1 addition & 0 deletions features/recipe-collection/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ dependencies {
implementation AndroidLibConfig.swipeRefresh

implementation project(ProjectConfig.analytics)
implementation project(Features.inAppUpdate)
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import kotlin.system.exitProcess

@ExperimentalCoroutinesApi
class RecipeCollectionActivity : AppCompatActivity() {
Expand Down Expand Up @@ -93,6 +94,9 @@ class RecipeCollectionActivity : AppCompatActivity() {
RecipeDetailIntent(recipeId = it.recipeId, title = it.title)
)
}
is RecipeCollectionCommand.FinishApp -> {
exitProcess(1)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ val recipeCollectionPresentationModule = module {
RecipeCollectionViewModel(
viewState = RecipeCollectionViewState(),
getRecipeCollection = get(),
analytics = get()
analytics = get(),
checkInAppUpdate = get(),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package br.com.recipebook.recipecollection.presentation

sealed class RecipeCollectionCommand {
object FinishApp : RecipeCollectionCommand()
data class OpenRecipeDetail(val recipeId: String, val title: String?) : RecipeCollectionCommand()
}
Loading

0 comments on commit 2810278

Please sign in to comment.