diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c89f2310843b..1073bc2bc60a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -514,6 +514,13 @@ dependencies { implementation(libs.ui) // endregion + // region NMC in-app-update + // Kotlin extensions library for Play In-App Update ref: https://developer.android.com/guide/playcore/in-app-updates/kotlin-java#groovy + "gplayImplementation"(libs.app.update.ktx) + // firebase remote config + "gplayImplementation"(libs.firebase.config) + // endregion + // region Image loading implementation(libs.coil) // endregion diff --git a/app/src/androidTest/java/com/nmc/android/AutoUpdateResourceTest.kt b/app/src/androidTest/java/com/nmc/android/AutoUpdateResourceTest.kt new file mode 100644 index 000000000000..b4638405deef --- /dev/null +++ b/app/src/androidTest/java/com/nmc/android/AutoUpdateResourceTest.kt @@ -0,0 +1,64 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nmc.android + +import android.content.Context +import android.content.res.Configuration +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.owncloud.android.R +import junit.framework.TestCase.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Locale + +/** + * Test class to verify the strings customized in this branch PR for NMC + */ +@RunWith(AndroidJUnit4::class) +class AutoUpdateResourceTest { + + private val baseContext = ApplicationProvider.getApplicationContext() + + private val localizedStringMap = mapOf( + R.string.app_update_downloaded to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "An update has just been downloaded.", + Locale.GERMAN to "Das Update wurde bereits heruntergeladen." + ) + ), + R.string.common_restart to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Restart", + Locale.GERMAN to "Neustart" + ) + ), + ) + + @Test + fun verifyLocalizedStrings() { + localizedStringMap.forEach { (stringRes, expected) -> + expected.translations.forEach { (locale, expectedText) -> + + val config = Configuration(baseContext.resources.configuration) + config.setLocale(locale) + + val localizedContext = baseContext.createConfigurationContext(config) + val actualText = localizedContext.getString(stringRes) + + assertEquals( + "Mismatch for ${baseContext.resources.getResourceEntryName(stringRes)} in $locale", + expectedText, + actualText + ) + } + } + } + + data class ExpectedLocalizedString(val translations: Map) +} \ No newline at end of file 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..6875ebfe54a9 --- /dev/null +++ b/app/src/gplay/java/com/nmc/android/appupdate/InAppUpdateHelperImpl.kt @@ -0,0 +1,165 @@ +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.Firebase +import com.google.firebase.remoteconfig.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.getBoolean(FORCE_UPDATE_KEY) + private val appVersionCode = remoteConfig.getLong(APP_VERSION_KEY) + + 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..c6158664ed7f --- /dev/null +++ b/app/src/gplay/java/com/nmc/android/remoteconfig/RemoteConfigInit.kt @@ -0,0 +1,70 @@ +package com.nmc.android.remoteconfig + +import android.app.Application +import com.google.firebase.Firebase +import com.google.firebase.FirebaseApp +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.remoteConfig +import com.google.firebase.remoteconfig.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(context: Application) { + + 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 var remoteConfig: FirebaseRemoteConfig + + init { + // init firebase + // fix: NMC-3449 + // fix: NMC-3848 + // Initialize Firebase (only if not already initialized) + if (FirebaseApp.getApps(context).isEmpty()) { + FirebaseApp.initializeApp(context) + } + + remoteConfig = Firebase.remoteConfig + + 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/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 e1b946e2781e..92525748f7ec 100644 --- a/app/src/main/java/com/owncloud/android/MainApp.java +++ b/app/src/main/java/com/owncloud/android/MainApp.java @@ -82,6 +82,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; @@ -364,6 +365,9 @@ public void onCreate() { backgroundJobManager.startPeriodicallyOfflineOperation(); } + // NMC Customization + new RemoteConfigInit(this); + registerGlobalPassCodeProtection(); networkChangeReceiver = new NetworkChangeReceiver(this, connectivityService); registerNetworkChangeReceiver(); 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 f4945899e9ea..5048d0284669 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; @@ -33,6 +34,8 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole; 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.lib.common.utils.Log_OC; @@ -43,6 +46,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; @@ -72,6 +76,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 @@ -352,4 +363,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/nmc_app_update_strings.xml b/app/src/main/res/values-de/nmc_app_update_strings.xml new file mode 100644 index 000000000000..4d5a232c8d0f --- /dev/null +++ b/app/src/main/res/values-de/nmc_app_update_strings.xml @@ -0,0 +1,5 @@ + + + Das Update wurde bereits heruntergeladen. + Neustart + \ No newline at end of file diff --git a/app/src/main/res/values/nmc_app_update_strings.xml b/app/src/main/res/values/nmc_app_update_strings.xml new file mode 100644 index 000000000000..1339cd1f3526 --- /dev/null +++ b/app/src/main/res/values/nmc_app_update_strings.xml @@ -0,0 +1,5 @@ + + + An update has just been downloaded. + Restart + \ No newline at end of file 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a24bcb59ab0d..f7a170d16058 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ annotationVersion = "1.9.1" annotationsVersion = "3.0.1u2" appCompatVersion = "1.7.1" bcpkixJdk18onVersion = "1.83" +appUpdateKtxVersion = "2.1.0" cardviewVersion = "1.0.0" checker = "3.21.2" coilVersion = "2.7.0" @@ -36,6 +37,7 @@ exifinterfaceVersion = "1.4.2" ezVcardVersion = "0.12.1" fbContribVersion = "7.7.1" findsecbugsPluginVersion = "1.14.0" +firebaseConfigVersion = "23.0.0" firebaseMessagingVersion = "25.0.1" flexboxVersion = "3.0.0" fragmentKtxVersion = "1.8.9" @@ -149,6 +151,8 @@ objenesis = { module = "org.objenesis:objenesis", version.ref = "objenesis" } play-services-base = { module = "com.google.android.gms:play-services-base", version.ref = "playServicesBaseVersion" } review-ktx = { module = "com.google.android.play:review-ktx", version.ref = "reviewKtxVersion" } slfj = { module = "org.slf4j:jcl-over-slf4j", version.ref = "slfj" } +app-update-ktx = { module = "com.google.android.play:app-update-ktx", version.ref = "appUpdateKtxVersion" } +firebase-config = { module = "com.google.firebase:firebase-config", version.ref = "firebaseConfigVersion" } # Mockito mockito-android = { module = "org.mockito:mockito-android", version.ref = "mockitoVersion" }