Skip to content

Commit

Permalink
Merge pull request #22 from motorro/migrate-to-activity-contracts
Browse files Browse the repository at this point in the history
Migrate to activity contracts
  • Loading branch information
motorro committed Nov 23, 2023
2 parents 33ed337 + 654fc0e commit a5240ab
Show file tree
Hide file tree
Showing 24 changed files with 221 additions and 167 deletions.
2 changes: 1 addition & 1 deletion .idea/kotlinc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 8 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ to simplify in-app update flow.
- Flexible updates are non-intrusive for app users with [UpdateFlowBreaker](#non-intrusive-flexible-updates-with-updateflowbreaker).

## Basics
Refer to [original documentation](https://developer.android.com/guide/app-bundle/in-app-updates) to understand
Refer to [original documentation](https://developer.android.com/guide/playcore/in-app-updates) to understand
the basics of in-app update. This library consists of two counterparts:
- [AppUpdateWrapper](appupdatewrapper/src/main/java/com/motorro/appupdatewrapper/AppUpdateWrapper.kt) is a presenter
(or presentation model to some extent) that is responsible for carrying out the `IMMEDIATE` or `FLEXIBLE` update
Expand Down Expand Up @@ -94,7 +94,7 @@ class TestActivity : AppCompatActivity(), AppUpdateView {
/********************************/

// AppUpdateManager needs your activity to start dialogs
override val activity: Activity get() = this
override val resultContractRegistry: ActivityResultRegistry = this.activityResultRegistry

// Called when flow starts
override fun updateChecking() {
Expand Down Expand Up @@ -142,13 +142,13 @@ dependencies {
application and `AppUpdateWrapper`. You may directly extend it in your hosting `Activity` or delegate it to some
fragment. Here are the methods you may implement:

#### activity (mandatory)
#### resultContractRegistry (mandatory)
```kotlin
val activity: Activity
val resultContractRegistry: ActivityResultRegistry
```
`AppUpdateManager` launches activities on behalf of your application. Implement this value to pass the activity that
will handle the `onActivityResult` and pass data to `AppUpdateWrapper.checkActivityResult`. Refer to method
[documentation](#checkactivityresult) to get the details.
`AppUpdateManager` launches activities on behalf of your application. Implement this value to pass the activity
result registry that will handle the `onActivityResult`. Typically you pass your activity `activityResultRegistry`
there.

#### updateReady (mandatory)
```kotlin
Expand Down Expand Up @@ -176,7 +176,7 @@ Called by presenter when update flow starts. UI may display a spinner of some ki
```kotlin
fun updateDownloadStarts()
```
Called by presenter user confirms flexible update and background download begins.
Called when user confirms flexible update and background download begins.
Called in flexible flow.

#### updateInstallUiVisible (optional)
Expand Down Expand Up @@ -212,19 +212,6 @@ The library supports both `IMMEDIATE` and `FLEXIBLE` update flows.

Both flows implement the `AppUpdateWrapper` interface with the following methods to consider:

#### checkActivityResult
```kotlin
fun checkActivityResult(requestCode: Int, resultCode: Int): Boolean
```
`AppUpdateManager` launches some activities from time to time: to ask for update consent, to install, etc. It does so
on behalf of your calling activity. Thus you must implement `onActivityResult` at your side and pass data to this method.
If `checkActivityResult` returns true - then the result was handled. See the sample at the [top](#basics) of the article.
In case your activity already uses the [request code](appupdatewrapper/src/main/java/com/motorro/appupdatewrapper/constants.kt#L23)
used for application updates you can set a new one by setting a static var:
```kotlin
AppUpdateWrapper.REQUEST_CODE_UPDATE = 1111
```

#### userCanceledUpdate and userConfirmedUpdate
```kotlin
fun userCanceledUpdate()
Expand Down
8 changes: 5 additions & 3 deletions appupdatewrapper/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,13 @@ android {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])

api "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"

api 'androidx.core:core-ktx:1.12.0'
api 'androidx.lifecycle:lifecycle-common:2.6.2'
api 'com.google.android.play:core:1.10.3'
api 'androidx.activity:activity-ktx:1.8.1'
api 'com.google.android.play:app-update:2.1.0'
api 'com.google.android.play:app-update-ktx:2.1.0'

implementation 'com.jakewharton.timber:timber:5.0.1'

Expand All @@ -77,7 +79,7 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0'
testImplementation 'org.robolectric:robolectric:4.10.3'
testImplementation 'org.robolectric:robolectric:4.11.1'
testImplementation 'androidx.lifecycle:lifecycle-runtime-testing:2.6.2'
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ internal abstract class AppUpdateState: AppUpdateWrapper, Tagged {
* Checks activity result and returns `true` if result is an update result and was handled
* Use to check update activity result in [android.app.Activity.onActivityResult]
*/
override fun checkActivityResult(requestCode: Int, resultCode: Int): Boolean = false
open fun checkActivityResult(resultCode: Int): Boolean = false

/**
* Cancels update installation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@

package com.motorro.appupdatewrapper

import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import com.google.android.play.core.appupdate.AppUpdateManager
import com.motorro.appupdatewrapper.AppUpdateWrapper.Companion.REQUEST_KEY_UPDATE

/**
* App update state machine
Expand All @@ -40,6 +44,11 @@ internal interface AppUpdateStateMachine {
*/
val view: AppUpdateView

/**
* Update request launcher
*/
val launcher: ActivityResultLauncher<IntentSenderRequest>

/**
* Sets new update state
*/
Expand All @@ -65,6 +74,12 @@ internal class AppUpdateLifecycleStateMachine(
@VisibleForTesting
var currentUpdateState: AppUpdateState

/**
* Update request launcher
*/
override lateinit var launcher: ActivityResultLauncher<IntentSenderRequest>
private set

init {
currentUpdateState = None()
lifecycle.addObserver(this)
Expand Down Expand Up @@ -94,6 +109,9 @@ internal class AppUpdateLifecycleStateMachine(
}

override fun onStart(owner: LifecycleOwner) {
launcher = view.resultContractRegistry.register(REQUEST_KEY_UPDATE, StartIntentSenderForResult()) {
checkActivityResult(it.resultCode)
}
currentUpdateState.onStart()
}

Expand All @@ -109,13 +127,17 @@ internal class AppUpdateLifecycleStateMachine(
currentUpdateState.onStop()
}

override fun onDestroy(owner: LifecycleOwner) {
launcher.unregister()
}

/**
* Checks activity result and returns `true` if result is an update result and was handled
* Use to check update activity result in [android.app.Activity.onActivityResult]
*/
override fun checkActivityResult(requestCode: Int, resultCode: Int): Boolean {
timber.d("Processing activity result: requestCode(%d), resultCode(%d)", requestCode, resultCode)
return currentUpdateState.checkActivityResult(requestCode, resultCode).also {
private fun checkActivityResult(resultCode: Int): Boolean {
timber.d("Processing activity result: resultCode(%d)", resultCode)
return currentUpdateState.checkActivityResult(resultCode).also {
timber.d("Activity result handled: %b", it)
}
}
Expand Down Expand Up @@ -143,6 +165,9 @@ internal class AppUpdateLifecycleStateMachine(
*/
override fun cleanup() {
lifecycle.removeObserver(this)
if (this::launcher.isInitialized) {
launcher.unregister()
}
currentUpdateState = None()
timber.d("Cleaned-up!")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

package com.motorro.appupdatewrapper

import android.app.Activity
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultRegistry
import androidx.lifecycle.Lifecycle.State.RESUMED

/**
Expand All @@ -32,12 +33,11 @@ import androidx.lifecycle.Lifecycle.State.RESUMED
*/
interface AppUpdateView {
/**
* Returns hosting activity for update process
* Call [AppUpdateState.checkActivityResult] in [Activity.onActivityResult] to
* check update status
* @see AppUpdateState.checkActivityResult
* Returns result contract registry
* Wrapper will register an activity result contract to listen to update state
* Pass [ComponentActivity.activityResultRegistry] or other registry to it
*/
val activity: Activity
val resultContractRegistry: ActivityResultRegistry

/**
* Called when update is checking or downloading data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@
package com.motorro.appupdatewrapper

import com.google.android.play.core.appupdate.AppUpdateManager
import com.motorro.appupdatewrapper.AppUpdateWrapper.Companion.REQUEST_KEY_UPDATE

/**
* Wraps [AppUpdateManager] interaction.
* The update wrapper is designed to be a single-use object. It carries out the workflow using host
* [androidx.lifecycle.Lifecycle] and terminates in either [AppUpdateView.updateComplete] or
* [AppUpdateView.updateFailed].
* [AppUpdateManager] pops up activities-for-result from time to time. To check if the activity result belongs to update
* flow call [checkActivityResult] function of update wrapper in your hosting activity.
* [AppUpdateManager] pops up activities-for-result from time to time. That is why [AppUpdateView.resultContractRegistry].
* The library registers the contract itself. If you need to change contract key - set [REQUEST_KEY_UPDATE]
* to the desired one
*/
interface AppUpdateWrapper {
companion object {
Expand All @@ -37,17 +39,11 @@ interface AppUpdateWrapper {
var USE_SAFE_LISTENERS = false

/**
* The request code wrapper uses to run [AppUpdateManager.startUpdateFlowForResult]
* The request key wrapper uses to register [AppUpdateManager] contract
*/
var REQUEST_CODE_UPDATE = REQUEST_CODE_UPDATE_DEFAULT
var REQUEST_KEY_UPDATE = REQUEST_KEY_UPDATE_DEFAULT
}

/**
* Checks activity result and returns `true` if result is an update result and was handled
* Use to check update activity result in [android.app.Activity.onActivityResult]
*/
fun checkActivityResult(requestCode: Int, resultCode: Int): Boolean

/**
* Cancels update installation
* Call when update is downloaded and user cancelled app restart
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,24 @@ package com.motorro.appupdatewrapper
import android.app.Activity
import androidx.annotation.CallSuper
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.AppUpdateOptions
import com.google.android.play.core.install.InstallStateUpdatedListener
import com.google.android.play.core.install.model.ActivityResult.RESULT_IN_APP_UPDATE_FAILED
import com.google.android.play.core.install.model.AppUpdateType.FLEXIBLE
import com.google.android.play.core.install.model.InstallErrorCode.ERROR_INSTALL_NOT_ALLOWED
import com.google.android.play.core.install.model.InstallErrorCode.ERROR_INSTALL_UNAVAILABLE
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.motorro.appupdatewrapper.AppUpdateException.Companion.ERROR_UNKNOWN_UPDATE_RESULT
import com.motorro.appupdatewrapper.AppUpdateException.Companion.ERROR_UPDATE_FAILED
import com.motorro.appupdatewrapper.AppUpdateException.Companion.ERROR_UPDATE_TYPE_NOT_ALLOWED
import com.motorro.appupdatewrapper.AppUpdateWrapper.Companion.REQUEST_CODE_UPDATE

/**
* Flexible update flow
Expand Down Expand Up @@ -102,9 +108,9 @@ internal sealed class FlexibleUpdateState : AppUpdateState(), Tagged {
* takes place. This may prevent download consent popup if activity was recreated during consent display
*/
@CallSuper
override fun checkActivityResult(requestCode: Int, resultCode: Int): Boolean {
timber.d("checkActivityResult: requestCode(%d), resultCode(%d)", requestCode, resultCode)
return if (REQUEST_CODE_UPDATE == requestCode && Activity.RESULT_CANCELED == resultCode) {
override fun checkActivityResult(resultCode: Int): Boolean {
timber.d("checkActivityResult: resultCode(%d)", resultCode)
return if (Activity.RESULT_CANCELED == resultCode) {
timber.d("Update download cancelled")
markUserCancelTime()
complete()
Expand Down Expand Up @@ -236,9 +242,8 @@ internal sealed class FlexibleUpdateState : AppUpdateState(), Tagged {

stateMachine.updateManager.startUpdateFlowForResult(
updateInfo,
FLEXIBLE,
activity,
REQUEST_CODE_UPDATE
stateMachine.launcher,
AppUpdateOptions.newBuilder(FLEXIBLE).build()
)
}
}
Expand All @@ -253,9 +258,8 @@ internal sealed class FlexibleUpdateState : AppUpdateState(), Tagged {
* Checks activity result and returns `true` if result is an update result and was handled
* Use to check update activity result in [android.app.Activity.onActivityResult]
*/
override fun checkActivityResult(requestCode: Int, resultCode: Int): Boolean = when {
super.checkActivityResult(requestCode, resultCode) -> true
REQUEST_CODE_UPDATE != requestCode -> false
override fun checkActivityResult(resultCode: Int): Boolean = when {
super.checkActivityResult(resultCode) -> true
else -> {
when(resultCode) {
Activity.RESULT_OK -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ package com.motorro.appupdatewrapper

import android.app.Activity
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.AppUpdateOptions
import com.google.android.play.core.install.model.AppUpdateType.IMMEDIATE
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.motorro.appupdatewrapper.AppUpdateException.Companion.ERROR_NO_IMMEDIATE_UPDATE
import com.motorro.appupdatewrapper.AppUpdateException.Companion.ERROR_UPDATE_FAILED
import com.motorro.appupdatewrapper.AppUpdateException.Companion.ERROR_UPDATE_TYPE_NOT_ALLOWED
import com.motorro.appupdatewrapper.AppUpdateWrapper.Companion.REQUEST_CODE_UPDATE

/**
* Immediate update flow
Expand Down Expand Up @@ -170,9 +170,8 @@ internal sealed class ImmediateUpdateState: AppUpdateState(), Tagged {

updateManager.startUpdateFlowForResult(
updateInfo,
IMMEDIATE,
activity,
REQUEST_CODE_UPDATE
stateMachine.launcher,
AppUpdateOptions.newBuilder(IMMEDIATE).build()
)
updateInstallUiVisible()
}
Expand All @@ -187,12 +186,8 @@ internal sealed class ImmediateUpdateState: AppUpdateState(), Tagged {
* Checks activity result and returns `true` if result is an update result and was handled
* Use to check update activity result in [android.app.Activity.onActivityResult]
*/
override fun checkActivityResult(requestCode: Int, resultCode: Int): Boolean {
timber.d("checkActivityResult: requestCode(%d), resultCode(%d)", requestCode, resultCode)
if (REQUEST_CODE_UPDATE != requestCode) {
return false
}

override fun checkActivityResult(resultCode: Int): Boolean {
timber.d("checkActivityResult: resultCode(%d)", resultCode)
if (Activity.RESULT_OK == resultCode) {
timber.d("Update installation complete")
complete()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,10 @@

package com.motorro.appupdatewrapper

import androidx.annotation.VisibleForTesting

/**
* Request code for update manager
*/
const val REQUEST_CODE_UPDATE_DEFAULT = 1050
const val REQUEST_KEY_UPDATE_DEFAULT = "AppUpdateWrapper"

/**
* SharedPreferences storage key for the time update was cancelled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import timber.log.Timber
* Starts flexible update
* Use to check for updates parallel to main application flow.
*
* If update found gets [AppUpdateView.activity] and starts play-core update consent on behalf of your activity.
* Therefore you should pass an activity result to the [AppUpdateWrapper.checkActivityResult] for check.
* If update found gets [AppUpdateView.resultContractRegistry] and starts play-core update consent on behalf of your activity.
* Therefore you need to implement a result registry in your view.
* Whenever the update is downloaded wrapper will call [AppUpdateView.updateReady]. At this point your view
* should ask if user is ready to restart application.
* Then call one of the continuation methods: [AppUpdateWrapper.userConfirmedUpdate] or [AppUpdateWrapper.userCanceledUpdate]
Expand Down
Loading

0 comments on commit a5240ab

Please sign in to comment.