diff --git a/app/build.gradle b/app/build.gradle index d6642a773b49..2470f9cddb37 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -415,6 +415,10 @@ dependencies { // upon each update first test: new registration, receive push gplayImplementation "com.google.firebase:firebase-messaging:23.4.1" gplayImplementation 'com.google.android.play:review-ktx:2.0.1' + // Kotlin extensions library for Play In-App Update ref: https://developer.android.com/guide/playcore/in-app-updates/kotlin-java#groovy + gplayImplementation 'com.google.android.play:app-update-ktx:2.1.0' + // firebase remote config + gplayImplementation("com.google.firebase:firebase-config-ktx:21.4.1") implementation 'com.github.nextcloud.android-common:ui:0.17.0' diff --git a/app/src/generic/java/com/nmc.android.appupdate/InAppUpdateHelperImpl.kt b/app/src/generic/java/com/nmc.android.appupdate/InAppUpdateHelperImpl.kt new file mode 100644 index 000000000000..647402e6dd57 --- /dev/null +++ b/app/src/generic/java/com/nmc.android.appupdate/InAppUpdateHelperImpl.kt @@ -0,0 +1,12 @@ +package com.nmc.android.appupdate + +import androidx.appcompat.app.AppCompatActivity + +class InAppUpdateHelperImpl(private val activity: AppCompatActivity) : InAppUpdateHelper { + + override fun onResume() { + } + + override fun onDestroy() { + } +} \ No newline at end of file diff --git a/app/src/generic/java/com/nmc/android/remoteconfig/RemoteConfigInit.kt b/app/src/generic/java/com/nmc/android/remoteconfig/RemoteConfigInit.kt new file mode 100644 index 000000000000..c4627249fa87 --- /dev/null +++ b/app/src/generic/java/com/nmc/android/remoteconfig/RemoteConfigInit.kt @@ -0,0 +1,6 @@ +package com.nmc.android.remoteconfig + +/** + * class to fetch and activate remote config for the app update feature + */ +class RemoteConfigInit \ No newline at end of file diff --git a/app/src/gplay/java/com/nmc/android/appupdate/InAppUpdateHelperImpl.kt b/app/src/gplay/java/com/nmc/android/appupdate/InAppUpdateHelperImpl.kt new file mode 100644 index 000000000000..5d9faa5fda79 --- /dev/null +++ b/app/src/gplay/java/com/nmc/android/appupdate/InAppUpdateHelperImpl.kt @@ -0,0 +1,166 @@ +package com.nmc.android.appupdate + +import android.app.Activity +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import com.google.android.material.snackbar.Snackbar +import com.google.android.play.core.appupdate.AppUpdateInfo +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +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.ActivityResult.RESULT_IN_APP_UPDATE_FAILED +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.google.firebase.ktx.Firebase +import com.google.firebase.remoteconfig.ktx.get +import com.google.firebase.remoteconfig.ktx.remoteConfig +import com.nmc.android.remoteconfig.RemoteConfigInit.Companion.APP_VERSION_KEY +import com.nmc.android.remoteconfig.RemoteConfigInit.Companion.FORCE_UPDATE_KEY +import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.DisplayUtils + +class InAppUpdateHelperImpl(private val activity: AppCompatActivity) : InAppUpdateHelper, InstallStateUpdatedListener { + + companion object { + private val TAG = InAppUpdateHelperImpl::class.java.simpleName + } + + private val remoteConfig = Firebase.remoteConfig + private val isForceUpdate = remoteConfig[FORCE_UPDATE_KEY].asBoolean() + private val appVersionCode = remoteConfig[APP_VERSION_KEY].asLong() + + private val appUpdateManager = AppUpdateManagerFactory.create(activity) + + @AppUpdateType + private var updateType = if (isForceUpdate) AppUpdateType.IMMEDIATE else AppUpdateType.FLEXIBLE + + init { + Log_OC.d(TAG, "App Update Remote Config Values : Force Update- $isForceUpdate -- Version Code- $appVersionCode") + + val appUpdateInfoTask = appUpdateManager.appUpdateInfo + + appUpdateInfoTask.addOnSuccessListener { appUpdateInfo -> + if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) { + Log_OC.d(TAG, "App update is available.") + + // if app version in remote config is not equal to the latest app version code in play store + // then do the flexible update instead of reading the value from remote config + if (appUpdateInfo.availableVersionCode() != appVersionCode.toInt()) { + Log_OC.d( + TAG, + "Available app version code mismatch with remote config. Setting update type to optional." + ) + updateType = AppUpdateType.FLEXIBLE + } + + if (appUpdateInfo.isUpdateTypeAllowed(updateType)) { + // Request the update. + startAppUpdate( + appUpdateInfo, + updateType + ) + } + } else { + Log_OC.d(TAG, "No app update available.") + } + } + } + + private fun startAppUpdate( + appUpdateInfo: AppUpdateInfo, + @AppUpdateType updateType: Int + ) { + + if (updateType == AppUpdateType.FLEXIBLE) { + // Before starting an update, register a listener for updates. + appUpdateManager.registerListener(this) + } + + Log_OC.d(TAG, "App update dialog showing to the user.") + + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + appUpdateResultLauncher, + AppUpdateOptions.newBuilder(updateType).build() + ) + } + + private val appUpdateResultLauncher: ActivityResultLauncher = + activity.registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result: ActivityResult -> + when (result.resultCode) { + Activity.RESULT_OK -> { + Log_OC.d(TAG, "The user has accepted to download the update or the download finished.") + } + + Activity.RESULT_CANCELED -> { + Log_OC.e(TAG, "Update flow failed: The user has denied or canceled the update.") + } + + RESULT_IN_APP_UPDATE_FAILED -> { + Log_OC.e( + TAG, + "Update flow failed: Some other error prevented either the user from providing consent or the update from proceeding." + ) + } + } + + } + + private fun flexibleUpdateDownloadCompleted() { + DisplayUtils.createSnackbar( + activity.findViewById(android.R.id.content), + R.string.app_update_downloaded, + Snackbar.LENGTH_INDEFINITE + ).apply { + setAction(R.string.common_restart) { appUpdateManager.completeUpdate() } + show() + } + } + + override fun onResume() { + appUpdateManager + .appUpdateInfo + .addOnSuccessListener { appUpdateInfo: AppUpdateInfo -> + // for AppUpdateType.IMMEDIATE only, already executing updater + if (updateType == AppUpdateType.IMMEDIATE) { + if (appUpdateInfo.updateAvailability() + == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS + ) { + Log_OC.d(TAG, "Resume the Immediate update if in-app update is already running.") + // If an in-app update is already running, resume the update. + startAppUpdate( + appUpdateInfo, + AppUpdateType.IMMEDIATE + ) + } + } else if (updateType == AppUpdateType.FLEXIBLE) { + // If the update is downloaded but not installed, + // notify the user to complete the update. + if (appUpdateInfo.installStatus() == InstallStatus.DOWNLOADED) { + Log_OC.d(TAG, "Resume: Flexible update is downloaded but not installed. User is notified.") + flexibleUpdateDownloadCompleted() + } + } + } + } + + override fun onDestroy() { + appUpdateManager.unregisterListener(this) + } + + override fun onStateUpdate(state: InstallState) { + if (state.installStatus() == InstallStatus.DOWNLOADED) { + Log_OC.d(TAG, "Flexible update is downloaded. User is notified to restart the app.") + + // After the update is downloaded, notifying user via snackbar + // and request user confirmation to restart the app. + flexibleUpdateDownloadCompleted() + } + } +} \ No newline at end of file diff --git a/app/src/gplay/java/com/nmc/android/remoteconfig/RemoteConfigInit.kt b/app/src/gplay/java/com/nmc/android/remoteconfig/RemoteConfigInit.kt new file mode 100644 index 000000000000..50cd148a559e --- /dev/null +++ b/app/src/gplay/java/com/nmc/android/remoteconfig/RemoteConfigInit.kt @@ -0,0 +1,57 @@ +package com.nmc.android.remoteconfig + +import com.google.firebase.ktx.Firebase +import com.google.firebase.remoteconfig.ktx.remoteConfig +import com.google.firebase.remoteconfig.ktx.remoteConfigSettings +import com.owncloud.android.BuildConfig +import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC +import java.util.concurrent.TimeUnit + +/** + * class to fetch and activate remote config for the app update feature + */ +class RemoteConfigInit { + + companion object { + private val TAG = RemoteConfigInit::class.java.simpleName + + const val FORCE_UPDATE_KEY = "android_force_update" + const val APP_VERSION_KEY = "android_app_version" + + private const val INTERVAL_FOR_DEVELOPMENT = 0L //0 sec for immediate update + + // by default the sync value is 12 hours which is not required in our case + // as we will be only using this for app update and since the app updates are done in few months + // so fetching the data in 1 day + private val INTERVAL_FOR_PROD = TimeUnit.DAYS.toSeconds(1) //1 day + + private fun getMinimumTimeToFetchConfigs(): Long { + return if (BuildConfig.DEBUG) INTERVAL_FOR_DEVELOPMENT else INTERVAL_FOR_PROD + } + } + + private val remoteConfig = Firebase.remoteConfig + + init { + val configSettings = remoteConfigSettings { + minimumFetchIntervalInSeconds = getMinimumTimeToFetchConfigs() + } + remoteConfig.setConfigSettingsAsync(configSettings) + remoteConfig.setDefaultsAsync(R.xml.remote_config_defaults) + + fetchAndActivateConfigs() + } + + private fun fetchAndActivateConfigs() { + remoteConfig.fetchAndActivate() + .addOnCompleteListener { task -> + if (task.isSuccessful) { + val updated = task.result + Log_OC.d(TAG, "Config params updated: $updated\nFetch and activate succeeded.") + } else { + Log_OC.e(TAG, "Fetch failed.") + } + } + } +} \ No newline at end of file diff --git a/app/src/gplay/res/values/setup.xml b/app/src/gplay/res/values/setup.xml index 1a44233a8c1f..236e8728baf7 100644 --- a/app/src/gplay/res/values/setup.xml +++ b/app/src/gplay/res/values/setup.xml @@ -9,14 +9,14 @@ https://push-notifications.nextcloud.com - 829118773643-cq33cmhv7mnv7iq8mjv6rt7t15afc70k.apps.googleusercontent.com - https://nextcloud-a7dea.firebaseio.com - 829118773643 - AIzaSyAWIyOcLafaFp8PFL61h64cy1NNZW2cU_s - 1:829118773643:android:512449826e931d0e - AIzaSyAWIyOcLafaFp8PFL61h64cy1NNZW2cU_s - nextcloud-a7dea.appspot.com - nextcloud-a7dea + 769898910423-mnfg2ntrfonapn4bu69q0j3mlgpqp4hl.apps.googleusercontent.com + https://mediencenter-1099.firebaseio.com + 769898910423 + AIzaSyCbRA2hRNewmQyBfchT-cLzU0GqXnYpVwU + 1:769898910423:android:bf1c31423c5299ba + AIzaSyCbRA2hRNewmQyBfchT-cLzU0GqXnYpVwU + mediencenter-1099.appspot.com + mediencenter-1099 diff --git a/app/src/main/java/com/nmc/android/appupdate/InAppUpdateHelper.kt b/app/src/main/java/com/nmc/android/appupdate/InAppUpdateHelper.kt new file mode 100644 index 000000000000..c4951ae6b81f --- /dev/null +++ b/app/src/main/java/com/nmc/android/appupdate/InAppUpdateHelper.kt @@ -0,0 +1,15 @@ +package com.nmc.android.appupdate + +interface InAppUpdateHelper { + /** + * function should be called from activity onResume + * to check if the update is downloaded or still in progress + */ + fun onResume() + + /** + * function should be called from activity onDestroy + * this will unregister the update listener attached for Flexible update + */ + fun onDestroy() +} \ No newline at end of file diff --git a/app/src/main/java/com/owncloud/android/MainApp.java b/app/src/main/java/com/owncloud/android/MainApp.java index f6d754b402ab..90c2e2da5391 100644 --- a/app/src/main/java/com/owncloud/android/MainApp.java +++ b/app/src/main/java/com/owncloud/android/MainApp.java @@ -74,6 +74,7 @@ import com.owncloud.android.lib.resources.status.NextcloudVersion; import com.owncloud.android.lib.resources.status.OwnCloudVersion; import com.owncloud.android.ui.activity.SyncedFoldersActivity; +import com.nmc.android.remoteconfig.RemoteConfigInit; import com.owncloud.android.ui.notifications.NotificationUtils; import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.FilesSyncHelper; @@ -355,6 +356,9 @@ public void onCreate() { backgroundJobManager.schedulePeriodicHealthStatus(); + // NMC Customization + new RemoteConfigInit(); + registerGlobalPassCodeProtection(); } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java index b018647ed0e2..1353c1254353 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java @@ -18,6 +18,7 @@ import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.drawable.Drawable; +import android.os.Bundle; import android.view.View; import android.widget.FrameLayout; import android.widget.ImageView; @@ -32,6 +33,8 @@ import com.google.android.material.textview.MaterialTextView; import com.nextcloud.client.di.Injectable; import com.owncloud.android.R; +import com.nmc.android.appupdate.InAppUpdateHelper; +import com.nmc.android.appupdate.InAppUpdateHelperImpl; import com.owncloud.android.datamodel.FileDataStorageManager; import com.owncloud.android.datamodel.OCFile; import com.owncloud.android.utils.theme.ThemeColorUtils; @@ -41,6 +44,7 @@ import javax.inject.Inject; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.ActionBar; import androidx.appcompat.widget.AppCompatSpinner; @@ -67,6 +71,13 @@ public abstract class ToolbarActivity extends BaseActivity implements Injectable @Inject public ThemeColorUtils themeColorUtils; @Inject public ThemeUtils themeUtils; @Inject public ViewThemeUtils viewThemeUtils; + private InAppUpdateHelper inAppUpdateHelper; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + inAppUpdateHelper = new InAppUpdateHelperImpl(this); + } /** * Toolbar setup that must be called in implementer's {@link #onCreate} after {@link #setContentView} if they want @@ -298,4 +309,19 @@ public void clearToolbarSubtitle() { actionBar.setSubtitle(null); } } + + @Override + protected void onResume() { + super.onResume(); + // Checks that the update is not stalled during 'onResume()'. + // However, you should execute this check at all entry points into the app. + inAppUpdateHelper.onResume(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + inAppUpdateHelper.onDestroy(); + inAppUpdateHelper = null; + } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5761430919e7..e7391ccd91a9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -124,6 +124,8 @@ Kein Text zum Kopieren in die Zwischenablage empfangen Link kopiert Unerwarteter Fehler beim Kopieren in die Zwischenablage + Das Update wurde bereits heruntergeladen. + Neustart Zurück Abbrechen Synchronisierung abbrechen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7ebed20a682..7b0ca78631ea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -171,6 +171,8 @@ Skip Copy About + An update has just been downloaded. + Restart Remove local account Remove account from device and delete all local files Request account deletion diff --git a/app/src/main/res/xml/remote_config_defaults.xml b/app/src/main/res/xml/remote_config_defaults.xml new file mode 100644 index 000000000000..eab9b406e38e --- /dev/null +++ b/app/src/main/res/xml/remote_config_defaults.xml @@ -0,0 +1,11 @@ + + + + android_force_update + false + + + android_app_version + 72123 + + \ No newline at end of file