Skip to content

Commit da5cd9a

Browse files
committed
In-App update implemented with Remote config.
1 parent d85ecc5 commit da5cd9a

File tree

13 files changed

+342
-8
lines changed

13 files changed

+342
-8
lines changed

app/build.gradle

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ apply plugin: 'io.gitlab.arturbosch.detekt'
4949
if (shotTest) {
5050
apply plugin: 'shot'
5151
}
52+
// apply In-App Update SDK for NMC
53+
apply from: "$rootProject.projectDir/nmc_app_update-dependencies.gradle"
5254
apply plugin: 'com.google.devtools.ksp'
5355

5456

@@ -426,6 +428,11 @@ dependencies {
426428

427429
implementation "io.coil-kt:coil:2.7.0"
428430

431+
// apply the dependencies from nmc_auto_update-dependencies.gradle
432+
// as auto update dependencies need to be included only in gplay variant
433+
// this is the way to do this
434+
project.ext.addDependencies(project)
435+
429436
// splash screen dependency ref: https://developer.android.com/develop/ui/views/launch/splash-screen/migrate
430437
implementation 'androidx.core:core-splashscreen:1.0.1'
431438
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.nmc.android.appupdate
2+
3+
import androidx.appcompat.app.AppCompatActivity
4+
5+
class InAppUpdateHelperImpl(private val activity: AppCompatActivity) : InAppUpdateHelper {
6+
7+
override fun onResume() {
8+
}
9+
10+
override fun onDestroy() {
11+
}
12+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.nmc.android.remoteconfig
2+
3+
/**
4+
* class to fetch and activate remote config for the app update feature
5+
*/
6+
class RemoteConfigInit
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package com.nmc.android.appupdate
2+
3+
import android.app.Activity
4+
import androidx.activity.result.ActivityResult
5+
import androidx.activity.result.ActivityResultLauncher
6+
import androidx.activity.result.IntentSenderRequest
7+
import androidx.activity.result.contract.ActivityResultContracts
8+
import androidx.appcompat.app.AppCompatActivity
9+
import com.google.android.material.snackbar.Snackbar
10+
import com.google.android.play.core.appupdate.AppUpdateInfo
11+
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
12+
import com.google.android.play.core.appupdate.AppUpdateOptions
13+
import com.google.android.play.core.install.InstallState
14+
import com.google.android.play.core.install.InstallStateUpdatedListener
15+
import com.google.android.play.core.install.model.ActivityResult.RESULT_IN_APP_UPDATE_FAILED
16+
import com.google.android.play.core.install.model.AppUpdateType
17+
import com.google.android.play.core.install.model.InstallStatus
18+
import com.google.android.play.core.install.model.UpdateAvailability
19+
import com.google.firebase.ktx.Firebase
20+
import com.google.firebase.remoteconfig.ktx.remoteConfig
21+
import com.nmc.android.remoteconfig.RemoteConfigInit.Companion.APP_VERSION_KEY
22+
import com.nmc.android.remoteconfig.RemoteConfigInit.Companion.FORCE_UPDATE_KEY
23+
import com.owncloud.android.R
24+
import com.owncloud.android.lib.common.utils.Log_OC
25+
import com.owncloud.android.utils.DisplayUtils
26+
27+
class InAppUpdateHelperImpl(private val activity: AppCompatActivity) : InAppUpdateHelper, InstallStateUpdatedListener {
28+
29+
companion object {
30+
private val TAG = InAppUpdateHelperImpl::class.java.simpleName
31+
}
32+
33+
private val remoteConfig = Firebase.remoteConfig
34+
private val isForceUpdate = remoteConfig.getBoolean(FORCE_UPDATE_KEY)
35+
private val appVersionCode = remoteConfig.getLong(APP_VERSION_KEY)
36+
37+
private val appUpdateManager = AppUpdateManagerFactory.create(activity)
38+
39+
@AppUpdateType
40+
private var updateType = if (isForceUpdate) AppUpdateType.IMMEDIATE else AppUpdateType.FLEXIBLE
41+
42+
init {
43+
Log_OC.d(TAG, "App Update Remote Config Values : Force Update- $isForceUpdate -- Version Code- $appVersionCode")
44+
45+
val appUpdateInfoTask = appUpdateManager.appUpdateInfo
46+
47+
appUpdateInfoTask.addOnSuccessListener { appUpdateInfo ->
48+
if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) {
49+
Log_OC.d(TAG, "App update is available.")
50+
51+
// if app version in remote config is not equal to the latest app version code in play store
52+
// then do the flexible update instead of reading the value from remote config
53+
if (appUpdateInfo.availableVersionCode() != appVersionCode.toInt()) {
54+
Log_OC.d(
55+
TAG,
56+
"Available app version code mismatch with remote config. Setting update type to optional."
57+
)
58+
updateType = AppUpdateType.FLEXIBLE
59+
}
60+
61+
if (appUpdateInfo.isUpdateTypeAllowed(updateType)) {
62+
// Request the update.
63+
startAppUpdate(
64+
appUpdateInfo,
65+
updateType
66+
)
67+
}
68+
} else {
69+
Log_OC.d(TAG, "No app update available.")
70+
}
71+
}
72+
}
73+
74+
private fun startAppUpdate(
75+
appUpdateInfo: AppUpdateInfo,
76+
@AppUpdateType updateType: Int
77+
) {
78+
79+
if (updateType == AppUpdateType.FLEXIBLE) {
80+
// Before starting an update, register a listener for updates.
81+
appUpdateManager.registerListener(this)
82+
}
83+
84+
Log_OC.d(TAG, "App update dialog showing to the user.")
85+
86+
appUpdateManager.startUpdateFlowForResult(
87+
appUpdateInfo,
88+
appUpdateResultLauncher,
89+
AppUpdateOptions.newBuilder(updateType).build()
90+
)
91+
}
92+
93+
private val appUpdateResultLauncher: ActivityResultLauncher<IntentSenderRequest> =
94+
activity.registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result: ActivityResult ->
95+
when (result.resultCode) {
96+
Activity.RESULT_OK -> {
97+
Log_OC.d(TAG, "The user has accepted to download the update or the download finished.")
98+
}
99+
100+
Activity.RESULT_CANCELED -> {
101+
Log_OC.e(TAG, "Update flow failed: The user has denied or canceled the update.")
102+
}
103+
104+
RESULT_IN_APP_UPDATE_FAILED -> {
105+
Log_OC.e(
106+
TAG,
107+
"Update flow failed: Some other error prevented either the user from providing consent or the update from proceeding."
108+
)
109+
}
110+
}
111+
112+
}
113+
114+
private fun flexibleUpdateDownloadCompleted() {
115+
DisplayUtils.createSnackbar(
116+
activity.findViewById(android.R.id.content),
117+
R.string.app_update_downloaded,
118+
Snackbar.LENGTH_INDEFINITE
119+
).apply {
120+
setAction(R.string.common_restart) { appUpdateManager.completeUpdate() }
121+
show()
122+
}
123+
}
124+
125+
override fun onResume() {
126+
appUpdateManager
127+
.appUpdateInfo
128+
.addOnSuccessListener { appUpdateInfo: AppUpdateInfo ->
129+
// for AppUpdateType.IMMEDIATE only, already executing updater
130+
if (updateType == AppUpdateType.IMMEDIATE) {
131+
if (appUpdateInfo.updateAvailability()
132+
== UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS
133+
) {
134+
Log_OC.d(TAG, "Resume the Immediate update if in-app update is already running.")
135+
// If an in-app update is already running, resume the update.
136+
startAppUpdate(
137+
appUpdateInfo,
138+
AppUpdateType.IMMEDIATE
139+
)
140+
}
141+
} else if (updateType == AppUpdateType.FLEXIBLE) {
142+
// If the update is downloaded but not installed,
143+
// notify the user to complete the update.
144+
if (appUpdateInfo.installStatus() == InstallStatus.DOWNLOADED) {
145+
Log_OC.d(TAG, "Resume: Flexible update is downloaded but not installed. User is notified.")
146+
flexibleUpdateDownloadCompleted()
147+
}
148+
}
149+
}
150+
}
151+
152+
override fun onDestroy() {
153+
appUpdateManager.unregisterListener(this)
154+
}
155+
156+
override fun onStateUpdate(state: InstallState) {
157+
if (state.installStatus() == InstallStatus.DOWNLOADED) {
158+
Log_OC.d(TAG, "Flexible update is downloaded. User is notified to restart the app.")
159+
160+
// After the update is downloaded, notifying user via snackbar
161+
// and request user confirmation to restart the app.
162+
flexibleUpdateDownloadCompleted()
163+
}
164+
}
165+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.nmc.android.remoteconfig
2+
3+
import android.app.Application
4+
import com.google.firebase.FirebaseApp
5+
import com.google.firebase.ktx.Firebase
6+
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
7+
import com.google.firebase.remoteconfig.ktx.remoteConfig
8+
import com.google.firebase.remoteconfig.ktx.remoteConfigSettings
9+
import com.owncloud.android.BuildConfig
10+
import com.owncloud.android.R
11+
import com.owncloud.android.lib.common.utils.Log_OC
12+
import java.util.concurrent.TimeUnit
13+
14+
/**
15+
* class to fetch and activate remote config for the app update feature
16+
*/
17+
class RemoteConfigInit(context: Application) {
18+
19+
companion object {
20+
private val TAG = RemoteConfigInit::class.java.simpleName
21+
22+
const val FORCE_UPDATE_KEY = "android_force_update"
23+
const val APP_VERSION_KEY = "android_app_version"
24+
25+
private const val INTERVAL_FOR_DEVELOPMENT = 0L //0 sec for immediate update
26+
27+
// by default the sync value is 12 hours which is not required in our case
28+
// as we will be only using this for app update and since the app updates are done in few months
29+
// so fetching the data in 1 day
30+
private val INTERVAL_FOR_PROD = TimeUnit.DAYS.toSeconds(1) //1 day
31+
32+
private fun getMinimumTimeToFetchConfigs(): Long {
33+
return if (BuildConfig.DEBUG) INTERVAL_FOR_DEVELOPMENT else INTERVAL_FOR_PROD
34+
}
35+
}
36+
37+
private var remoteConfig: FirebaseRemoteConfig
38+
39+
init {
40+
// init firebase
41+
// fix: NMC-3449
42+
FirebaseApp.initializeApp(context)
43+
44+
remoteConfig = Firebase.remoteConfig
45+
46+
val configSettings = remoteConfigSettings {
47+
minimumFetchIntervalInSeconds = getMinimumTimeToFetchConfigs()
48+
}
49+
remoteConfig.setConfigSettingsAsync(configSettings)
50+
remoteConfig.setDefaultsAsync(R.xml.remote_config_defaults)
51+
52+
fetchAndActivateConfigs()
53+
}
54+
55+
private fun fetchAndActivateConfigs() {
56+
remoteConfig.fetchAndActivate()
57+
.addOnCompleteListener { task ->
58+
if (task.isSuccessful) {
59+
val updated = task.result
60+
Log_OC.d(TAG, "Config params updated: $updated\nFetch and activate succeeded.")
61+
} else {
62+
Log_OC.e(TAG, "Fetch failed.")
63+
}
64+
}
65+
}
66+
}

app/src/gplay/res/values/setup.xml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@
99
<!-- Push server url -->
1010
<string name="push_server_url" translatable="false">https://push-notifications.nextcloud.com</string>
1111

12-
<string name="default_web_client_id" translatable="false">829118773643-cq33cmhv7mnv7iq8mjv6rt7t15afc70k.apps.googleusercontent.com</string>
13-
<string name="firebase_database_url" translatable="false">https://nextcloud-a7dea.firebaseio.com</string>
14-
<string name="gcm_defaultSenderId" translatable="false">829118773643</string>
15-
<string name="google_api_key" translatable="false">AIzaSyAWIyOcLafaFp8PFL61h64cy1NNZW2cU_s</string>
16-
<string name="google_app_id" translatable="false">1:829118773643:android:512449826e931d0e</string>
17-
<string name="google_crash_reporting_api_key" translatable="false">AIzaSyAWIyOcLafaFp8PFL61h64cy1NNZW2cU_s</string>
18-
<string name="google_storage_bucket" translatable="false">nextcloud-a7dea.appspot.com</string>
19-
<string name="project_id" translatable="false">nextcloud-a7dea</string>
12+
<string name="default_web_client_id" translatable="false">769898910423-mnfg2ntrfonapn4bu69q0j3mlgpqp4hl.apps.googleusercontent.com</string>
13+
<string name="firebase_database_url" translatable="false">https://mediencenter-1099.firebaseio.com</string>
14+
<string name="gcm_defaultSenderId" translatable="false">769898910423</string>
15+
<string name="google_api_key" translatable="false">AIzaSyCbRA2hRNewmQyBfchT-cLzU0GqXnYpVwU</string>
16+
<string name="google_app_id" translatable="false">1:769898910423:android:bf1c31423c5299ba</string>
17+
<string name="google_crash_reporting_api_key" translatable="false">AIzaSyCbRA2hRNewmQyBfchT-cLzU0GqXnYpVwU</string>
18+
<string name="google_storage_bucket" translatable="false">mediencenter-1099.appspot.com</string>
19+
<string name="project_id" translatable="false">mediencenter-1099</string>
2020
</resources>
2121

2222

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.nmc.android.appupdate
2+
3+
interface InAppUpdateHelper {
4+
/**
5+
* function should be called from activity onResume
6+
* to check if the update is downloaded or still in progress
7+
*/
8+
fun onResume()
9+
10+
/**
11+
* function should be called from activity onDestroy
12+
* this will unregister the update listener attached for Flexible update
13+
*/
14+
fun onDestroy()
15+
}

app/src/main/java/com/owncloud/android/MainApp.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
import com.owncloud.android.lib.resources.status.NextcloudVersion;
8484
import com.owncloud.android.lib.resources.status.OwnCloudVersion;
8585
import com.owncloud.android.ui.activity.SyncedFoldersActivity;
86+
import com.nmc.android.remoteconfig.RemoteConfigInit;
8687
import com.owncloud.android.ui.notifications.NotificationUtils;
8788
import com.owncloud.android.utils.DisplayUtils;
8889
import com.owncloud.android.utils.FilesSyncHelper;
@@ -385,6 +386,9 @@ public void onCreate() {
385386
backgroundJobManager.startPeriodicallyOfflineOperation();
386387
}
387388

389+
// NMC Customization
390+
new RemoteConfigInit(this);
391+
388392
registerGlobalPassCodeProtection();
389393
networkChangeReceiver = new NetworkChangeReceiver(this, connectivityService);
390394
registerNetworkChangeReceiver();

app/src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import android.graphics.Bitmap;
1919
import android.graphics.Color;
2020
import android.graphics.drawable.Drawable;
21+
import android.os.Bundle;
2122
import android.view.View;
2223
import android.widget.FrameLayout;
2324
import android.widget.ImageView;
@@ -32,6 +33,8 @@
3233
import com.google.android.material.textview.MaterialTextView;
3334
import com.nextcloud.client.di.Injectable;
3435
import com.owncloud.android.R;
36+
import com.nmc.android.appupdate.InAppUpdateHelper;
37+
import com.nmc.android.appupdate.InAppUpdateHelperImpl;
3538
import com.owncloud.android.datamodel.FileDataStorageManager;
3639
import com.owncloud.android.datamodel.OCFile;
3740
import com.owncloud.android.utils.theme.ThemeColorUtils;
@@ -41,6 +44,7 @@
4144
import javax.inject.Inject;
4245

4346
import androidx.annotation.NonNull;
47+
import androidx.annotation.Nullable;
4448
import androidx.annotation.StringRes;
4549
import androidx.appcompat.app.ActionBar;
4650
import androidx.appcompat.widget.AppCompatSpinner;
@@ -67,6 +71,13 @@ public abstract class ToolbarActivity extends BaseActivity implements Injectable
6771
@Inject public ThemeColorUtils themeColorUtils;
6872
@Inject public ThemeUtils themeUtils;
6973
@Inject public ViewThemeUtils viewThemeUtils;
74+
private InAppUpdateHelper inAppUpdateHelper;
75+
76+
@Override
77+
protected void onCreate(@Nullable Bundle savedInstanceState) {
78+
super.onCreate(savedInstanceState);
79+
inAppUpdateHelper = new InAppUpdateHelperImpl(this);
80+
}
7081

7182
/**
7283
* Toolbar setup that must be called in implementer's {@link #onCreate} after {@link #setContentView} if they want
@@ -302,4 +313,19 @@ public void clearToolbarSubtitle() {
302313
actionBar.setSubtitle(null);
303314
}
304315
}
316+
317+
@Override
318+
protected void onResume() {
319+
super.onResume();
320+
// Checks that the update is not stalled during 'onResume()'.
321+
// However, you should execute this check at all entry points into the app.
322+
inAppUpdateHelper.onResume();
323+
}
324+
325+
@Override
326+
protected void onDestroy() {
327+
super.onDestroy();
328+
inAppUpdateHelper.onDestroy();
329+
inAppUpdateHelper = null;
330+
}
305331
}

0 commit comments

Comments
 (0)