Skip to content

Commit

Permalink
Merge pull request #20822 from wordpress-mobile/pantelis/in-app-updates
Browse files Browse the repository at this point in the history
Feature: IN-APP UPDATES
  • Loading branch information
pantstamp committed May 20, 2024
2 parents 3596041 + c138065 commit 595f399
Show file tree
Hide file tree
Showing 21 changed files with 872 additions and 4 deletions.
4 changes: 4 additions & 0 deletions WordPress/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ android {
buildConfigField "boolean", "DASHBOARD_PERSONALIZATION", "false"
buildConfigField "boolean", "ENABLE_SITE_MONITORING", "false"
buildConfigField "boolean", "SYNC_PUBLISHING", "false"
buildConfigField "boolean", "ENABLE_IN_APP_UPDATES", "false"

manifestPlaceholders = [magicLinkScheme:"wordpress"]
}
Expand Down Expand Up @@ -391,6 +392,9 @@ dependencies {
implementation "org.wordpress:persistentedittext:$wordPressPersistentEditTextVersion"
implementation "$gradle.ext.gravatarBinaryPath:$gravatarVersion"

implementation "com.google.android.play:app-update:$googlePlayInAppUpdateVersion"
implementation "com.google.android.play:app-update-ktx:$googlePlayInAppUpdateVersion"

implementation "androidx.arch.core:core-common:$androidxArchCoreVersion"
implementation "androidx.arch.core:core-runtime:$androidxArchCoreVersion"
implementation "com.google.code.gson:gson:$googleGsonVersion"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.wordpress.android.util.config

const val IN_APP_UPDATE_BLOCKING_VERSION_REMOTE_FIELD = "jp_in_app_update_blocking_version_android"


Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,6 @@ class AppInitializer @Inject constructor(
crashLogging.initialize()
dispatcher.register(this)
appConfig.init(appScope)

// Upload any encrypted logs that were queued but not yet uploaded
encryptedLogging.start()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.wordpress.android.inappupdate

import android.app.Activity

interface IInAppUpdateManager {
fun checkForAppUpdate(activity: Activity, listener: InAppUpdateListener)
fun completeAppUpdate()
fun cancelAppUpdate(updateType: Int)
fun onUserAcceptedAppUpdate(updateType: Int)

companion object {
const val APP_UPDATE_IMMEDIATE_REQUEST_CODE = 1001
const val APP_UPDATE_FLEXIBLE_REQUEST_CODE = 1002
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.wordpress.android.inappupdate

import com.google.android.play.core.install.model.AppUpdateType
import org.wordpress.android.analytics.AnalyticsTracker
import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper
import javax.inject.Inject

class InAppUpdateAnalyticsTracker @Inject constructor(
private val tracker: AnalyticsTrackerWrapper
) {
fun trackUpdateShown(updateType: Int) {
tracker.track(AnalyticsTracker.Stat.IN_APP_UPDATE_SHOWN, createPropertyMap(updateType))
}

fun trackUpdateAccepted(updateType: Int) {
tracker.track(AnalyticsTracker.Stat.IN_APP_UPDATE_ACCEPTED, createPropertyMap(updateType))
}

fun trackUpdateDismissed(updateType: Int) {
tracker.track(AnalyticsTracker.Stat.IN_APP_UPDATE_DISMISSED, createPropertyMap(updateType))
}

fun trackAppRestartToCompleteUpdate() {
tracker.track(AnalyticsTracker.Stat.IN_APP_UPDATE_COMPLETED_WITH_APP_RESTART)
}

private fun createPropertyMap(updateType: Int): Map<String, String> {
return when (updateType) {
AppUpdateType.FLEXIBLE -> mapOf(PROPERTY_UPDATE_TYPE to UPDATE_TYPE_FLEXIBLE)
AppUpdateType.IMMEDIATE -> mapOf(PROPERTY_UPDATE_TYPE to UPDATE_TYPE_BLOCKING)
else -> emptyMap()
}
}

companion object {
const val PROPERTY_UPDATE_TYPE = "type"
const val UPDATE_TYPE_FLEXIBLE = "flexible"
const val UPDATE_TYPE_BLOCKING = "blocking"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.wordpress.android.inappupdate

/**
* Abstract class for handling callbacks related to in-app update events.
*
* Each method provides a default implementation that does nothing, allowing
* implementers to only override the necessary methods without implementing
* all callback methods.
*/
abstract class InAppUpdateListener {
open fun onAppUpdateStarted(type: Int) {
// Default empty implementation
}

open fun onAppUpdateDownloaded() {
// Default empty implementation
}

open fun onAppUpdateInstalled() {
// Default empty implementation
}

open fun onAppUpdateFailed() {
// Default empty implementation
}

open fun onAppUpdateCancelled() {
// Default empty implementation
}

open fun onAppUpdatePending() {
// Default empty implementation
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
package org.wordpress.android.inappupdate

import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.util.Log
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.InstallStateUpdatedListener
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.InstallStatus.CANCELED
import com.google.android.play.core.install.model.InstallStatus.DOWNLOADED
import com.google.android.play.core.install.model.InstallStatus.DOWNLOADING
import com.google.android.play.core.install.model.InstallStatus.FAILED
import com.google.android.play.core.install.model.InstallStatus.INSTALLED
import com.google.android.play.core.install.model.InstallStatus.INSTALLING
import com.google.android.play.core.install.model.InstallStatus.PENDING
import com.google.android.play.core.install.model.UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS
import com.google.android.play.core.install.model.UpdateAvailability.UPDATE_AVAILABLE
import com.google.android.play.core.install.model.UpdateAvailability.UPDATE_NOT_AVAILABLE
import dagger.hilt.android.qualifiers.ApplicationContext
import org.wordpress.android.inappupdate.IInAppUpdateManager.Companion.APP_UPDATE_FLEXIBLE_REQUEST_CODE
import org.wordpress.android.inappupdate.IInAppUpdateManager.Companion.APP_UPDATE_IMMEDIATE_REQUEST_CODE

import org.wordpress.android.util.BuildConfigWrapper
import org.wordpress.android.util.config.RemoteConfigWrapper
import javax.inject.Singleton

@Singleton
@Suppress("TooManyFunctions")
class InAppUpdateManagerImpl(
@ApplicationContext private val applicationContext: Context,
private val appUpdateManager: AppUpdateManager,
private val remoteConfigWrapper: RemoteConfigWrapper,
private val buildConfigWrapper: BuildConfigWrapper,
private val inAppUpdateAnalyticsTracker: InAppUpdateAnalyticsTracker,
private val currentTimeProvider: () -> Long = {System.currentTimeMillis()}
): IInAppUpdateManager {
private var updateListener: InAppUpdateListener? = null

override fun checkForAppUpdate(activity: Activity, listener: InAppUpdateListener) {
updateListener = listener
appUpdateManager.appUpdateInfo.addOnSuccessListener { appUpdateInfo ->
handleUpdateInfoSuccess(appUpdateInfo, activity)
}.addOnFailureListener { exception ->
Log.e(TAG, "Failed to check for update: ${exception.message}")
}
}

override fun completeAppUpdate() {
inAppUpdateAnalyticsTracker.trackAppRestartToCompleteUpdate()
appUpdateManager.completeUpdate()
}

override fun cancelAppUpdate(updateType: Int) {
appUpdateManager.unregisterListener(installStateListener)
inAppUpdateAnalyticsTracker.trackUpdateDismissed(updateType)
}

override fun onUserAcceptedAppUpdate(updateType: Int) {
inAppUpdateAnalyticsTracker.trackUpdateAccepted(updateType)
}

private fun handleUpdateInfoSuccess(appUpdateInfo: AppUpdateInfo, activity: Activity) {
when (appUpdateInfo.updateAvailability()) {
UPDATE_NOT_AVAILABLE -> {
/* do nothing */
}
UPDATE_AVAILABLE -> {
handleUpdateAvailable(appUpdateInfo, activity)
}
DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS -> {
handleUpdateInProgress(appUpdateInfo, activity)
}
else -> { /* do nothing */ }
}
}

private fun handleUpdateAvailable(appUpdateInfo: AppUpdateInfo, activity: Activity) {
if (appUpdateInfo.installStatus() == DOWNLOADED) {
updateListener?.onAppUpdateDownloaded()
return
}

val updateVersion = getAvailableUpdateAppVersion(appUpdateInfo)
if (updateVersion != getLastUpdateRequestedVersion()) {
resetLastUpdateRequestInfo()
}

if (isImmediateUpdateNecessary()) {
if (shouldRequestImmediateUpdate()) {
requestImmediateUpdate(appUpdateInfo, activity)
}
} else if (shouldRequestFlexibleUpdate()) {
requestFlexibleUpdate(appUpdateInfo, activity)
}
}

private fun handleUpdateInProgress(appUpdateInfo: AppUpdateInfo, activity: Activity) {
if (isImmediateUpdateInProgress(appUpdateInfo)) {
requestImmediateUpdate(appUpdateInfo, activity)
} else {
requestFlexibleUpdate(appUpdateInfo, activity)
}
}

private fun requestImmediateUpdate(appUpdateInfo: AppUpdateInfo, activity: Activity) {
updateListener?.onAppUpdateStarted(AppUpdateType.IMMEDIATE)
requestUpdate(AppUpdateType.IMMEDIATE, appUpdateInfo, activity)
}

private fun requestFlexibleUpdate(appUpdateInfo: AppUpdateInfo, activity: Activity) {
appUpdateManager.registerListener(installStateListener)
updateListener?.onAppUpdateStarted(AppUpdateType.FLEXIBLE)
requestUpdate(AppUpdateType.FLEXIBLE, appUpdateInfo, activity)
}

@Suppress("TooGenericExceptionCaught")
private fun requestUpdate(updateType: Int, appUpdateInfo: AppUpdateInfo, activity: Activity) {
saveLastUpdateRequestInfo(appUpdateInfo)
val requestCode = if (updateType == AppUpdateType.IMMEDIATE) {
APP_UPDATE_IMMEDIATE_REQUEST_CODE
} else {
APP_UPDATE_FLEXIBLE_REQUEST_CODE
}
try {
appUpdateManager.startUpdateFlowForResult(
appUpdateInfo,
activity,
AppUpdateOptions.newBuilder(updateType).build(),
requestCode
)
inAppUpdateAnalyticsTracker.trackUpdateShown(updateType)
} catch (e: Exception) {
Log.e(TAG, "requestUpdate for type: $updateType, exception occurred")
Log.e(TAG, e.message.toString())
appUpdateManager.unregisterListener(installStateListener)
}
}

private val installStateListener = object : InstallStateUpdatedListener {
@SuppressLint("SwitchIntDef")
override fun onStateUpdate(state: InstallState) {
when (state.installStatus()) {
DOWNLOADED -> {
updateListener?.onAppUpdateDownloaded()
}
INSTALLED -> {
updateListener?.onAppUpdateInstalled()
appUpdateManager.unregisterListener(this) // 'this' refers to the listener object
}
CANCELED -> {
updateListener?.onAppUpdateCancelled()
appUpdateManager.unregisterListener(this)
}
FAILED -> {
updateListener?.onAppUpdateFailed()
appUpdateManager.unregisterListener(this)
}
PENDING -> {
updateListener?.onAppUpdatePending()
}
DOWNLOADING, INSTALLING, InstallStatus.UNKNOWN -> {
/* do nothing */
}
}
}
}

private fun isImmediateUpdateInProgress(appUpdateInfo: AppUpdateInfo) =
appUpdateInfo.updateAvailability() == DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS
&& appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
&& isImmediateUpdateNecessary()

private fun saveLastUpdateRequestInfo(appUpdateInfo: AppUpdateInfo) {
val sharedPref = applicationContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
sharedPref.edit().apply {
putInt(KEY_LAST_APP_UPDATE_CHECK_VERSION, getAvailableUpdateAppVersion(appUpdateInfo))
putLong(KEY_LAST_APP_UPDATE_CHECK_TIME, currentTimeProvider.invoke())
apply()
}
}

private fun resetLastUpdateRequestInfo() {
val sharedPref = applicationContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
sharedPref.edit().apply {
putInt(KEY_LAST_APP_UPDATE_CHECK_VERSION, -1)
putLong(KEY_LAST_APP_UPDATE_CHECK_TIME, -1L)
apply()
}
}

private fun getLastUpdateRequestedVersion() =
applicationContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.getInt(KEY_LAST_APP_UPDATE_CHECK_VERSION, -1)

private fun getLastUpdateRequestedTime() =
applicationContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.getLong(KEY_LAST_APP_UPDATE_CHECK_TIME, -1L)

private fun shouldRequestFlexibleUpdate() =
currentTimeProvider.invoke() - getLastUpdateRequestedTime() >= getFlexibleUpdateIntervalInMillis()

private fun shouldRequestImmediateUpdate() =
currentTimeProvider.invoke() - getLastUpdateRequestedTime() >= IMMEDIATE_UPDATE_INTERVAL_IN_MILLIS

@Suppress("MagicNumber")
private fun getFlexibleUpdateIntervalInMillis(): Long =
1000 * 60 * 60 * 24 * remoteConfigWrapper.getInAppUpdateFlexibleIntervalInDays().toLong()

private fun getCurrentAppVersion() = buildConfigWrapper.getAppVersionCode()

private fun getLastBlockingAppVersion(): Int = remoteConfigWrapper.getInAppUpdateBlockingVersion()

private fun getAvailableUpdateAppVersion(appUpdateInfo: AppUpdateInfo) = appUpdateInfo.availableVersionCode()

private fun isImmediateUpdateNecessary() = getCurrentAppVersion() < getLastBlockingAppVersion()

companion object {
const val IMMEDIATE_UPDATE_INTERVAL_IN_MILLIS = 1000 * 60 * 5 // 5 minutes
const val KEY_LAST_APP_UPDATE_CHECK_TIME = "last_app_update_check_time"

private const val TAG = "AppUpdateChecker"
private const val PREF_NAME = "in_app_update_prefs"
private const val KEY_LAST_APP_UPDATE_CHECK_VERSION = "last_app_update_check_version"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.wordpress.android.inappupdate

import android.app.Activity

class InAppUpdateManagerNoop: IInAppUpdateManager {
override fun checkForAppUpdate(activity: Activity, listener: InAppUpdateListener) {
/* Empty implementation */
}

override fun completeAppUpdate() {
/* Empty implementation */
}

override fun cancelAppUpdate(updateType: Int) {
/* Empty implementation */
}

override fun onUserAcceptedAppUpdate(updateType: Int) {
/* Empty implementation */
}
}
Loading

0 comments on commit 595f399

Please sign in to comment.