diff --git a/.aiexclude b/.aiexclude new file mode 100644 index 000000000000..05433f9baf75 --- /dev/null +++ b/.aiexclude @@ -0,0 +1,39 @@ +# OS X generated file +.DS_Store + +# Build-related files +fastlane/ + +# Key-related files +.jks +.keystore + +# Backup files +.bak + +# Generated files +bin/ +gen/ +build/ +build.log + +# Built application files +.apk +.ap_ +.aab + +# Dex VM files +.dex + +# Configuration files +.configure +.configure-files/ +google-services.json +google-upload-credentials.json +firebase.secrets.json +sentry.properties + +# Gradle files +gradle.properties +local.properties +local-builds.gradle diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 87d96c8032e3..c6c5356e0c4d 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -29,6 +29,16 @@ steps: ################# - group: "🕵️‍♂️ Linters" steps: + - label: "☢️ Danger - PR Check" + command: danger + key: danger + if: "build.pull_request.id != null" + retry: + manual: + permit_on_passed: true + agents: + queue: "linter" + - label: "🕵️ checkstyle" command: | cp gradle.properties-example gradle.properties diff --git a/.github/workflows/run-danger.yml b/.github/workflows/run-danger.yml index 856ab8cea46d..1031d64a7dd9 100644 --- a/.github/workflows/run-danger.yml +++ b/.github/workflows/run-danger.yml @@ -1,13 +1,17 @@ -name: ☢️ Danger +name: ☢️ Trigger Danger On Buildkite on: pull_request: - types: [opened, reopened, ready_for_review, synchronize, edited, labeled, unlabeled, milestoned, demilestoned] + types: [labeled, unlabeled, milestoned, demilestoned] jobs: dangermattic: - # runs on draft PRs only for opened / synchronize events - if: ${{ (github.event.pull_request.draft == false) || (github.event.pull_request.draft == true && contains(fromJSON('["opened", "synchronize"]'), github.event.action)) }} - uses: Automattic/dangermattic/.github/workflows/reusable-run-danger.yml@v1.0.0 + if: ${{ (github.event.pull_request.draft == false) }} + uses: Automattic/dangermattic/.github/workflows/reusable-retry-buildkite-step-on-events.yml@v1.1.0 + with: + org-slug: "automattic" + pipeline-slug: "wordpress-android" + retry-step-key: "danger" + build-commit-sha: "${{ github.event.pull_request.head.sha }}" secrets: - github-token: ${{ secrets.DANGERMATTIC_GITHUB_TOKEN }} + buildkite-api-token: ${{ secrets.TRIGGER_BK_BUILD_TOKEN }} diff --git a/Dangerfile b/Dangerfile index d439496653f9..ef55a1240632 100644 --- a/Dangerfile +++ b/Dangerfile @@ -3,7 +3,8 @@ github.dismiss_out_of_range_messages # `files: []` forces rubocop to scan all files, not just the ones modified in the PR -rubocop.lint(files: [], force_exclusion: true, inline_comment: true, fail_on_inline_comment: true, include_cop_names: true) +# Added a custom `rubocop_cmd` to prevent RuboCop from running using `bundle exec`, which we don't want on the linter agent +rubocop.lint(files: [], force_exclusion: true, inline_comment: true, fail_on_inline_comment: true, include_cop_names: true, rubocop_cmd: ': | rubocop') manifest_pr_checker.check_gemfile_lock_updated diff --git a/Gemfile.lock b/Gemfile.lock index 8d2a8b367b52..631493a457ee 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -69,29 +69,16 @@ GEM no_proxy_fix octokit (>= 4.0) terminal-table (>= 1, < 4) - danger-dangermattic (1.0.0) + danger-dangermattic (1.0.2) danger (~> 9.4) - danger-junit (~> 1.0) danger-plugin-api (~> 1.0) danger-rubocop (~> 0.12) - danger-swiftlint (~> 0.35) - danger-xcode_summary (~> 1.0) - rubocop (~> 1.60) - danger-junit (1.0.2) - danger (> 2.0) - ox (~> 2.0) + rubocop (~> 1.61) danger-plugin-api (1.0.0) danger (> 2.0) danger-rubocop (0.12.0) danger rubocop (~> 1.0) - danger-swiftlint (0.35.0) - danger - rake (> 10) - thor (~> 1.0.0) - danger-xcode_summary (1.3.0) - danger-plugin-api (~> 1.0) - xcresult (~> 0.2) declarative (0.0.20) diffy (3.4.2) digest-crc (0.6.5) @@ -241,7 +228,7 @@ GEM concurrent-ruby (~> 1.0) java-properties (0.3.0) jmespath (1.6.2) - json (2.7.1) + json (2.7.2) jwt (2.8.0) base64 kramdown (2.4.0) @@ -254,7 +241,7 @@ GEM mini_portile2 (2.8.5) minitest (5.22.2) multi_json (1.15.0) - multipart-post (2.4.0) + multipart-post (2.4.1) mutex_m (0.2.0) nanaimo (0.3.0) nap (1.1.0) @@ -271,23 +258,22 @@ GEM options (2.3.2) optparse (0.4.0) os (1.1.4) - ox (2.14.17) parallel (1.24.0) - parser (3.3.0.5) + parser (3.3.1.0) ast (~> 2.4.1) racc plist (3.7.1) progress_bar (1.3.3) highline (>= 1.6, < 3) options (~> 2.3.0) - public_suffix (5.0.4) + public_suffix (5.0.5) racc (1.7.3) rainbow (3.1.1) - rake (13.1.0) + rake (13.2.1) rake-compiler (1.2.7) rake rchardet (1.8.0) - regexp_parser (2.9.0) + regexp_parser (2.9.1) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -296,7 +282,7 @@ GEM rexml (3.2.6) rmagick (4.3.0) rouge (2.0.7) - rubocop (1.60.2) + rubocop (1.63.5) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -304,11 +290,11 @@ GEM rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.30.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.30.0) - parser (>= 3.2.1.0) + rubocop-ast (1.31.3) + parser (>= 3.3.1.0) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) rubyzip (2.3.2) @@ -327,7 +313,6 @@ GEM terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) - thor (1.0.1) trailblazer-option (0.1.2) tty-cursor (0.7.1) tty-screen (0.8.2) @@ -349,7 +334,6 @@ GEM rouge (~> 2.0.7) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) - xcresult (0.2.1) PLATFORMS ruby diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 2f150f031f95..a3128872399b 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -2,7 +2,14 @@ 25.0 ----- - +* [*] Fixed a rare crash on Posts List screen [https://github.com/wordpress-mobile/WordPress-Android/pull/20813] +* [*] Fixed a rare crash on the Login screen [https://github.com/wordpress-mobile/WordPress-Android/pull/20821] +* [*] Fix a crash that occurs when remove a user [https://github.com/wordpress-mobile/WordPress-Android/pull/20837] +* [*] Fixed a rare crash on the featured image confirmation dialog [https://github.com/wordpress-mobile/WordPress-Android/pull/20836] +* [*] Fixed an ANR issue on the Post List screen [https://github.com/wordpress-mobile/WordPress-Android/pull/20833] +* [*] Fixed a crash that occurs with Blogging Reminders [https://github.com/wordpress-mobile/WordPress-Android/pull/20845] +* [*] [internal] Block Editor: Upgrade target sdk version to Android API 34 [https://github.com/wordpress-mobile/WordPress-Android/pull/20841] +* [*] [internal] In-app updates feature [https://github.com/wordpress-mobile/WordPress-Android/pull/20822] 24.9 ----- diff --git a/WordPress/build.gradle b/WordPress/build.gradle index 9e3d56af9f2d..5d2535b7266d 100644 --- a/WordPress/build.gradle +++ b/WordPress/build.gradle @@ -151,6 +151,7 @@ android { buildConfigField "boolean", "READER_DISCOVER_NEW_ENDPOINT", "false" buildConfigField "boolean", "READER_READING_PREFERENCES", "false" buildConfigField "boolean", "READER_READING_PREFERENCES_FEEDBACK", "false" + buildConfigField "boolean", "VOICE_TO_CONTENT", "false" // Override these constants in jetpack product flavor to enable/ disable features buildConfigField "boolean", "ENABLE_SITE_CREATION", "true" @@ -166,6 +167,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"] } @@ -391,6 +393,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" diff --git a/WordPress/src/jetpack/java/org/wordpress/android/util/config/InAppUpdateBlockingVersionConfigConstants.kt b/WordPress/src/jetpack/java/org/wordpress/android/util/config/InAppUpdateBlockingVersionConfigConstants.kt new file mode 100644 index 000000000000..3d539360338c --- /dev/null +++ b/WordPress/src/jetpack/java/org/wordpress/android/util/config/InAppUpdateBlockingVersionConfigConstants.kt @@ -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" + + diff --git a/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt b/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt index 2ad4a34dbaa2..bfe756467ab2 100644 --- a/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt +++ b/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt @@ -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() diff --git a/WordPress/src/main/java/org/wordpress/android/inappupdate/IInAppUpdateManager.kt b/WordPress/src/main/java/org/wordpress/android/inappupdate/IInAppUpdateManager.kt new file mode 100644 index 000000000000..fc1135f274c8 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/inappupdate/IInAppUpdateManager.kt @@ -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 + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateAnalyticsTracker.kt b/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateAnalyticsTracker.kt new file mode 100644 index 000000000000..abf15b094bfd --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateAnalyticsTracker.kt @@ -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 { + 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" + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateListener.kt b/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateListener.kt new file mode 100644 index 000000000000..e002395f3cd9 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateListener.kt @@ -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 + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateManagerImpl.kt b/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateManagerImpl.kt new file mode 100644 index 000000000000..11ac7fc132e1 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateManagerImpl.kt @@ -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" + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateManagerNoop.kt b/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateManagerNoop.kt new file mode 100644 index 000000000000..d732dc62d7e9 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateManagerNoop.kt @@ -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 */ + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java b/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java index 9814ec12f66a..ed712b9d32e3 100644 --- a/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java +++ b/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java @@ -8,11 +8,17 @@ import androidx.lifecycle.LiveData; import androidx.preference.PreferenceManager; +import com.google.android.play.core.appupdate.AppUpdateManager; +import com.google.android.play.core.appupdate.AppUpdateManagerFactory; import com.tenor.android.core.network.ApiClient; import com.tenor.android.core.network.ApiService; import com.tenor.android.core.network.IApiClient; import org.wordpress.android.BuildConfig; +import org.wordpress.android.inappupdate.IInAppUpdateManager; +import org.wordpress.android.inappupdate.InAppUpdateAnalyticsTracker; +import org.wordpress.android.inappupdate.InAppUpdateManagerImpl; +import org.wordpress.android.inappupdate.InAppUpdateManagerNoop; import org.wordpress.android.ui.ActivityNavigator; import org.wordpress.android.ui.jetpack.backup.download.BackupDownloadStep; import org.wordpress.android.ui.jetpack.backup.download.BackupDownloadStepsProvider; @@ -21,6 +27,9 @@ import org.wordpress.android.ui.mediapicker.loader.TenorGifClient; import org.wordpress.android.ui.sitecreation.SiteCreationStep; import org.wordpress.android.ui.sitecreation.SiteCreationStepsProvider; +import org.wordpress.android.util.BuildConfigWrapper; +import org.wordpress.android.util.config.InAppUpdatesFeatureConfig; +import org.wordpress.android.util.config.RemoteConfigWrapper; import org.wordpress.android.util.wizard.WizardManager; import org.wordpress.android.viewmodel.helpers.ConnectionStatus; import org.wordpress.android.viewmodel.helpers.ConnectionStatusLiveData; @@ -76,6 +85,33 @@ public static WizardManager provideRestoreWizardManager( return new WizardManager<>(stepsProvider.getSteps()); } + @Provides + public static AppUpdateManager provideAppUpdateManager(@ApplicationContext Context context) { + return AppUpdateManagerFactory.create(context); + } + + @Provides + public static IInAppUpdateManager provideInAppUpdateManager( + @ApplicationContext Context context, + AppUpdateManager appUpdateManager, + RemoteConfigWrapper remoteConfigWrapper, + BuildConfigWrapper buildConfigWrapper, + InAppUpdatesFeatureConfig inAppUpdatesFeatureConfig, + InAppUpdateAnalyticsTracker inAppUpdateAnalyticsTracker + ) { + // Check if in-app updates feature is enabled + return inAppUpdatesFeatureConfig.isEnabled() + ? new InAppUpdateManagerImpl( + context, + appUpdateManager, + remoteConfigWrapper, + buildConfigWrapper, + inAppUpdateAnalyticsTracker, + System::currentTimeMillis + ) + : new InAppUpdateManagerNoop(); + } + @Provides public static ActivityNavigator provideActivityNavigator(@ApplicationContext Context context) { return new ActivityNavigator(); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/SmartLockHelper.java b/WordPress/src/main/java/org/wordpress/android/ui/accounts/SmartLockHelper.java index 0bac7f2b6a63..e0d0b0fe1278 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/SmartLockHelper.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/SmartLockHelper.java @@ -13,7 +13,6 @@ import com.google.android.gms.auth.api.Auth; import com.google.android.gms.auth.api.credentials.Credential; import com.google.android.gms.auth.api.credentials.CredentialRequest; -import com.google.android.gms.auth.api.credentials.CredentialRequestResult; import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.GoogleApiAvailability; import com.google.android.gms.common.api.CommonStatusCodes; @@ -82,33 +81,30 @@ public void smartLockAutoFill(@NonNull final Callback callback) { .setPasswordLoginSupported(true) .build(); Auth.CredentialsApi.request(mCredentialsClient, credentialRequest).setResultCallback( - new ResultCallback() { - @Override - public void onResult(@NonNull CredentialRequestResult result) { - Status status = result.getStatus(); - if (status.isSuccess()) { - Credential credential = result.getCredential(); - callback.onCredentialRetrieved(credential); - } else { - if (status.getStatusCode() == CommonStatusCodes.RESOLUTION_REQUIRED) { - try { - Activity activity = getActivityAndCheckAvailability(); - if (activity == null) { - return; - } - // Prompt the user to choose a saved credential - status.startResolutionForResult(activity, RequestCodes.SMART_LOCK_READ); - } catch (IntentSender.SendIntentException e) { - AppLog.d(T.NUX, "SmartLock: Failed to send resolution for credential request"); - - callback.onCredentialsUnavailable(); - } - } else { - // The user must create an account or log in manually. - AppLog.d(T.NUX, "SmartLock: Unsuccessful credential request."); + result -> { + Activity currentActivity = getActivityAndCheckAvailability(); + if (currentActivity == null) { + return; + } + Status status = result.getStatus(); + if (status.isSuccess()) { + Credential credential = result.getCredential(); + callback.onCredentialRetrieved(credential); + } else { + if (status.getStatusCode() == CommonStatusCodes.RESOLUTION_REQUIRED) { + try { + // Prompt the user to choose a saved credential + status.startResolutionForResult(currentActivity, RequestCodes.SMART_LOCK_READ); + } catch (IntentSender.SendIntentException e) { + AppLog.d(T.NUX, "SmartLock: Failed to send resolution for credential request"); callback.onCredentialsUnavailable(); } + } else { + // The user must create an account or log in manually. + AppLog.d(T.NUX, "SmartLock: Unsuccessful credential request."); + + callback.onCredentialsUnavailable(); } } }); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/debug/preferences/DebugPrefs.kt b/WordPress/src/main/java/org/wordpress/android/ui/debug/preferences/DebugPrefs.kt new file mode 100644 index 000000000000..7dda8cc7bf70 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/debug/preferences/DebugPrefs.kt @@ -0,0 +1,10 @@ +package org.wordpress.android.ui.debug.preferences + +import kotlin.reflect.KClass + +/** + * Class used to track debuggable shared preferences and will show up in [DebugSharedPreferenceFlagsActivity]. + */ +enum class DebugPrefs(val key: String, val type: KClass<*>) { + ALWAYS_SHOW_ANNOUNCEMENT("prefs_always_show_announcement", Boolean::class) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/debug/preferences/DebugSharedPreferenceFlagsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/debug/preferences/DebugSharedPreferenceFlagsViewModel.kt index b7c99e4487e6..a88c1cb1ed4f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/debug/preferences/DebugSharedPreferenceFlagsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/debug/preferences/DebugSharedPreferenceFlagsViewModel.kt @@ -18,7 +18,13 @@ class DebugSharedPreferenceFlagsViewModel @Inject constructor( val flags = prefsWrapper.getAllPrefs().mapNotNull { (key, value) -> if (value is Boolean) key to value else null }.toMap() - _uiStateFlow.value = flags + + val explicitFlags = DebugPrefs.entries.mapNotNull { + // Only supporting boolean for now. + if (it.type == Boolean::class) it else null + }.associate { it.key to prefsWrapper.getDebugBooleanPref(it.key, false) } + + _uiStateFlow.value = flags + explicitFlags } fun setFlag(key: String, value: Boolean) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/history/HistoryDetailContainerFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/history/HistoryDetailContainerFragment.java index f51a98efabfa..61d2d2a59700 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/history/HistoryDetailContainerFragment.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/history/HistoryDetailContainerFragment.java @@ -172,6 +172,9 @@ private ArrayList mapRevisions() { mRevision = getArguments().getParcelable(EXTRA_CURRENT_REVISION); final long[] previousRevisionsIds = getArguments().getLongArray(EXTRA_PREVIOUS_REVISIONS_IDS); + if (previousRevisionsIds == null) { + return null; + } final List revisionModels = new ArrayList<>(); final long postId = getArguments().getLong(EXTRA_POST_ID); final long siteId = getArguments().getLong(EXTRA_SITE_ID); @@ -192,7 +195,9 @@ private ArrayList mapRevisionModelsToRevisions(@Nullable final List revisions = new ArrayList<>(); for (int i = 0; i < revisionModels.size(); i++) { final RevisionModel current = revisionModels.get(i); - revisions.add(new Revision(current)); + if (current != null) { + revisions.add(new Revision(current)); + } } return revisions; } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java index dffbacaeb591..d15191ccd33d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java @@ -10,6 +10,7 @@ import android.os.Handler; import android.os.Looper; import android.text.TextUtils; +import android.util.Log; import android.view.HapticFeedbackConstants; import android.view.View; import android.view.ViewGroup; @@ -30,6 +31,7 @@ import com.google.android.gms.tasks.Task; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.snackbar.Snackbar; +import com.google.android.play.core.install.model.AppUpdateType; import com.google.android.play.core.review.ReviewInfo; import com.google.android.play.core.review.ReviewManager; import com.google.android.play.core.review.ReviewManagerFactory; @@ -38,6 +40,8 @@ import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import org.wordpress.android.BuildConfig; +import org.wordpress.android.inappupdate.InAppUpdateListener; +import org.wordpress.android.inappupdate.IInAppUpdateManager; import org.wordpress.android.R; import org.wordpress.android.WordPress; import org.wordpress.android.analytics.AnalyticsTracker; @@ -174,6 +178,7 @@ import org.wordpress.android.viewmodel.mlp.ModalLayoutPickerViewModel; import org.wordpress.android.viewmodel.mlp.ModalLayoutPickerViewModel.CreatePageDashboardSource; import org.wordpress.android.widgets.AppRatingDialog; +import org.wordpress.android.widgets.WPSnackbar; import org.wordpress.android.workers.notification.createsite.CreateSiteNotificationScheduler; import org.wordpress.android.workers.weeklyroundup.WeeklyRoundupScheduler; @@ -296,6 +301,8 @@ public class WPMainActivity extends LocaleAwareActivity implements @Inject BuildConfigWrapper mBuildConfigWrapper; + @Inject IInAppUpdateManager mInAppUpdateManager; + @Inject GCMRegistrationScheduler mGCMRegistrationScheduler; @Inject ActivityNavigator mActivityNavigator; @@ -1196,9 +1203,30 @@ protected void onResume() { mSelectedSiteRepository.hasSelectedSite() && mBottomNav != null && mBottomNav.getCurrentSelectedPage() == PageType.MY_SITE ); + + checkForInAppUpdate(); + mIsChangingConfiguration = false; } + private void checkForInAppUpdate() { + mInAppUpdateManager.checkForAppUpdate(this, mInAppUpdateListener); + } + + @NonNull final InAppUpdateListener mInAppUpdateListener = new InAppUpdateListener() { + @Override public void onAppUpdateDownloaded() { + popupSnackbarForCompleteUpdate(); + } + }; + + private void popupSnackbarForCompleteUpdate() { + WPSnackbar.make(findViewById(R.id.coordinator), R.string.in_app_update_available, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.in_app_update_restart, v -> { + mInAppUpdateManager.completeAppUpdate(); + }) + .show(); + } + private void checkQuickStartNotificationStatus() { SiteModel selectedSite = getSelectedSite(); long selectedSiteLocalId = mSelectedSiteRepository.getSelectedSiteLocalId(); @@ -1350,6 +1378,7 @@ private void setSite(Intent data) { @Override @SuppressWarnings("deprecation") public void onActivityResult(int requestCode, int resultCode, Intent data) { + Log.e("WPMainActivity", "onActivityResult: " + requestCode + " " + resultCode); super.onActivityResult(requestCode, resultCode, data); if (!mSelectedSiteRepository.hasSelectedSite()) { initSelectedSite(); @@ -1463,6 +1492,23 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { case RequestCodes.DOMAIN_REGISTRATION: passOnActivityResultToMySiteFragment(requestCode, resultCode, data); break; + case IInAppUpdateManager.APP_UPDATE_FLEXIBLE_REQUEST_CODE: + handleUpdateResult(resultCode, AppUpdateType.FLEXIBLE); + break; + case IInAppUpdateManager.APP_UPDATE_IMMEDIATE_REQUEST_CODE: + handleUpdateResult(resultCode, AppUpdateType.IMMEDIATE); + break; + } + } + + // Handles the result of the app update request + private void handleUpdateResult(int resultCode, int updateType) { + if (resultCode == RESULT_OK) { + // The user accepted the update + mInAppUpdateManager.onUserAcceptedAppUpdate(updateType); + } else if (resultCode == RESULT_CANCELED) { + // The user denied the update + mInAppUpdateManager.cancelAppUpdate(updateType); } } @@ -1884,6 +1930,7 @@ public void onSetPromptReminderClick(final int siteId) { onActivityResult(RequestCodes.SITE_PICKER, resultCode, data); } + // We dismiss the QuickStart SnackBar every time activity is paused because // SnackBar sometimes do not appear when another SnackBar is still visible, even in other activities (weird) @Override diff --git a/WordPress/src/main/java/org/wordpress/android/ui/people/PeopleManagementActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/people/PeopleManagementActivity.java index 0add5ecb5c0b..ca3a29e92275 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/people/PeopleManagementActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/people/PeopleManagementActivity.java @@ -604,7 +604,7 @@ private void refreshDetailFragment() { private boolean navigateBackToPeopleListFragment() { FragmentManager fragmentManager = getSupportFragmentManager(); - if (fragmentManager.getBackStackEntryCount() > 0) { + if (!fragmentManager.isStateSaved() && fragmentManager.getBackStackEntryCount() > 0) { fragmentManager.popBackStack(); ActionBar actionBar = getSupportActionBar(); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt index 4da1601f9492..c89268b5853a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt @@ -209,6 +209,7 @@ class PostsListActivity : LocaleAwareActivity(), setupActionBar() setupContent() initViewModel(initPreviewState, currentBottomSheetPostId) + initSearchFragment() initBloggingReminders() initInAppReviews() initTabLayout(tabIndex) @@ -492,7 +493,6 @@ class PostsListActivity : LocaleAwareActivity(), authorFilterMenuItem = menu.findItem(R.id.author_filter_menu_item) searchActionButton = menu.findItem(R.id.toggle_search) - initSearchFragment() binding.initSearchView() initAuthorFilter(authorFilterMenuItem) return true diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PublishSettingsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PublishSettingsViewModel.kt index d8672ec395f5..8d2bd9dc1f4d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/PublishSettingsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PublishSettingsViewModel.kt @@ -23,6 +23,7 @@ import org.wordpress.android.util.LocaleManagerWrapper import org.wordpress.android.viewmodel.Event import org.wordpress.android.viewmodel.ResourceProvider import java.util.Calendar +import java.util.Date abstract class PublishSettingsViewModel constructor( @@ -212,7 +213,10 @@ constructor( val dateCreated = postRepository.dateCreated // Set the currently selected time if available if (!TextUtils.isEmpty(dateCreated)) { - calendar.time = DateTimeUtils.dateFromIso8601(dateCreated) + // Calendar.setTime(Date date) expects a non-null Date object + val maybeDate: Date? = DateTimeUtils.dateFromIso8601(dateCreated) + maybeDate?.let { date -> calendar.time = date } + calendar.timeZone = localeManagerWrapper.getTimeZone() } return calendar diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java index 0396fb813f35..5bf4bdb7824b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java @@ -387,6 +387,10 @@ public static boolean getBoolean(PrefKey key, boolean def) { return Boolean.parseBoolean(value); } + public static boolean getRawBoolean(@NonNull final PrefKey key, boolean def) { + return prefs().getBoolean(key.name(), def); + } + public static void putBoolean(final PrefKey key, final boolean value) { prefs().edit().putBoolean(key.name(), value).apply(); } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt index e844020b246f..4a835daca578 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt @@ -19,6 +19,7 @@ import org.wordpress.android.ui.stats.refresh.lists.widget.configuration.StatsDa import org.wordpress.android.ui.stats.refresh.lists.widget.configuration.StatsDataTypeSelectionViewModel.DataType.VIEWS import org.wordpress.android.ui.stats.refresh.lists.widget.configuration.StatsDataTypeSelectionViewModel.DataType.VISITORS import org.wordpress.android.usecase.social.JetpackSocialFlow +import org.wordpress.android.util.BuildConfigWrapper import java.util.Date import javax.inject.Inject import javax.inject.Singleton @@ -31,7 +32,7 @@ import javax.inject.Singleton * */ @Singleton -class AppPrefsWrapper @Inject constructor() { +class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWrapper) { var featureAnnouncementShownVersion: Int get() = AppPrefs.getFeatureAnnouncementShownVersion() set(version) = AppPrefs.setFeatureAnnouncementShownVersion(version) @@ -451,6 +452,9 @@ class AppPrefsWrapper @Inject constructor() { fun getAllPrefs(): Map = AppPrefs.getAllPrefs() + fun getDebugBooleanPref(key: String, default: Boolean = false) = + buildConfigWrapper.isDebug() && AppPrefs.getRawBoolean({ key }, default) + fun setString(prefKey: PrefKey, value: String) { AppPrefs.setString(prefKey, value) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java index 4469e5763261..7c072d98a450 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java @@ -1301,7 +1301,7 @@ private void initBloggingReminders() { } private void setupBloggingRemindersBottomSheet() { - if (mBloggingRemindersPref == null || !isAdded()) { + if (mBloggingRemindersPref == null || !isAdded() || mSite == null || mBloggingRemindersViewModel == null) { return; } mBloggingRemindersViewModel.onBlogSettingsItemClicked(mSite.getId()); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderSiteHeaderView.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderSiteHeaderView.java index 3fd01fae0109..7516e164beae 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderSiteHeaderView.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderSiteHeaderView.java @@ -3,6 +3,8 @@ import android.content.Context; import android.icu.text.CompactDecimalFormat; import android.icu.text.NumberFormat; +import android.os.Handler; +import android.os.Looper; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; @@ -31,6 +33,9 @@ import org.wordpress.android.util.image.BlavatarShape; import org.wordpress.android.util.image.ImageManager; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + import javax.inject.Inject; /** @@ -55,6 +60,9 @@ public interface OnBlogInfoLoadedListener { private OnBlogInfoLoadedListener mBlogInfoListener; private OnFollowListener mFollowListener; + private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor(); + private final Handler mMainHandler = new Handler(Looper.getMainLooper()); + @Inject AccountStore mAccountStore; @Inject ImageManager mImageManager; @Inject ReaderTracker mReaderTracker; @@ -103,7 +111,6 @@ public void loadBlogInfo( mBlogId = blogId; mFeedId = feedId; - final ReaderBlog localBlogInfo; if (blogId == 0 && feedId == 0) { ToastUtils.showToast(getContext(), R.string.reader_toast_err_show_blog); return; @@ -111,33 +118,35 @@ public void loadBlogInfo( mIsFeed = ReaderUtils.isExternalFeed(mBlogId, mFeedId); - if (mIsFeed) { - localBlogInfo = ReaderBlogTable.getFeedInfo(mFeedId); - } else { - localBlogInfo = ReaderBlogTable.getBlogInfo(mBlogId); - } - - if (localBlogInfo != null) { - showBlogInfo(localBlogInfo, source); - } - - // then get from server if doesn't exist locally or is time to update it - if (localBlogInfo == null || ReaderBlogTable.isTimeToUpdateBlogInfo(localBlogInfo)) { - ReaderActions.UpdateBlogInfoListener listener = new ReaderActions.UpdateBlogInfoListener() { - @Override - public void onResult(ReaderBlog serverBlogInfo) { - if (isAttachedToWindow()) { - showBlogInfo(serverBlogInfo, source); - } - } - }; - + // run in background to avoid ANR + mExecutorService.execute(() -> { + final ReaderBlog localBlogInfo; if (mIsFeed) { - ReaderBlogActions.updateFeedInfo(mFeedId, null, listener); + localBlogInfo = ReaderBlogTable.getFeedInfo(mFeedId); } else { - ReaderBlogActions.updateBlogInfo(mBlogId, null, listener); + localBlogInfo = ReaderBlogTable.getBlogInfo(mBlogId); } - } + + mMainHandler.post(() -> { + if (localBlogInfo != null) { + showBlogInfo(localBlogInfo, source); + } + // then get from server if doesn't exist locally or is time to update it + if (localBlogInfo == null || ReaderBlogTable.isTimeToUpdateBlogInfo(localBlogInfo)) { + ReaderActions.UpdateBlogInfoListener listener = serverBlogInfo -> { + if (isAttachedToWindow()) { + showBlogInfo(serverBlogInfo, source); + } + }; + + if (mIsFeed) { + ReaderBlogActions.updateFeedInfo(mFeedId, null, listener); + } else { + ReaderBlogActions.updateBlogInfo(mBlogId, null, listener); + } + } + }); + }); } private void showBlogInfo(ReaderBlog blogInfo, String source) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitecreation/SiteCreationMainVM.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitecreation/SiteCreationMainVM.kt index a7f69cc2fde0..978fe2561f65 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/sitecreation/SiteCreationMainVM.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitecreation/SiteCreationMainVM.kt @@ -347,9 +347,10 @@ class SiteCreationMainVM @Inject constructor( _onCompleted.value = NotCreated to isSiteTitleTaskCompleted() } - fun onWizardFinished(result: Created) { - siteCreationState = siteCreationState.copy(result = result) - _onCompleted.value = result to isSiteTitleTaskCompleted() + fun onWizardFinished(result: Created?) { + val nullCheckedResult = result ?: NotCreated + siteCreationState = siteCreationState.copy(result = nullCheckedResult) + _onCompleted.value = nullCheckedResult to isSiteTitleTaskCompleted() } private fun isSiteTitleTaskCompleted() = !siteCreationState.siteName.isNullOrBlank() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitecreation/previews/SitePreviewViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitecreation/previews/SitePreviewViewModel.kt index 84f96ab53e6c..d3e9554b21e6 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/sitecreation/previews/SitePreviewViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitecreation/previews/SitePreviewViewModel.kt @@ -69,8 +69,7 @@ class SitePreviewViewModel @Inject constructor( private var siteDesign: String? = null private var isFree: Boolean = true - private lateinit var result: Created - private lateinit var domainName: String + private var result: Created? = null private val _uiState: MutableLiveData = MutableLiveData() val uiState: LiveData = _uiState @@ -78,8 +77,8 @@ class SitePreviewViewModel @Inject constructor( private val _preloadPreview: MutableLiveData = MutableLiveData() val preloadPreview: LiveData = _preloadPreview - private val _onOkButtonClicked = SingleLiveEvent() - val onOkButtonClicked: LiveData = _onOkButtonClicked + private val _onOkButtonClicked = SingleLiveEvent() + val onOkButtonClicked: LiveData = _onOkButtonClicked fun start(siteCreationState: SiteCreationState) { if (isStarted) return else isStarted = true @@ -90,12 +89,13 @@ class SitePreviewViewModel @Inject constructor( siteDesign = siteCreationState.siteDesign result = siteCreationState.result isFree = requireNotNull(siteCreationState.domain).isFree - domainName = getCleanUrl(result.site.url) ?: "" startPreLoadingWebView() - if (result is CreatedButNotFetched) { - launch { - fetchNewlyCreatedSiteModel(result.site.siteId)?.let { - result = Completed(it) + result?.let { + if (it is CreatedButNotFetched) { + launch { + fetchNewlyCreatedSiteModel(it.site.siteId)?.let { + result = Completed(it) + } } } } @@ -121,7 +121,7 @@ class SitePreviewViewModel @Inject constructor( } } // Load the newly created site in the webview - result.site.url?.let { url -> + result?.site?.url?.let { url -> val urlToLoad = urlUtils.addUrlSchemeIfNeeded( url = url, addHttps = isWordPressComSubDomain(url) @@ -172,7 +172,7 @@ class SitePreviewViewModel @Inject constructor( private fun getCleanUrl(url: String) = StringUtils.removeTrailingSlash(urlUtils.removeScheme(url)) private fun createSitePreviewData(): UrlData { - val url = domainName + val url = result?.let { getCleanUrl(it.site.url) ?: "" } ?: "" val subDomain = urlUtils.extractSubDomain(url) val fullUrl = urlUtils.addUrlSchemeIfNeeded(url, true) val subDomainIndices = 0 to subDomain.length diff --git a/WordPress/src/main/java/org/wordpress/android/util/config/InAppUpdateBlockingVersionConfig.kt b/WordPress/src/main/java/org/wordpress/android/util/config/InAppUpdateBlockingVersionConfig.kt new file mode 100644 index 000000000000..c6df149a0f0a --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/config/InAppUpdateBlockingVersionConfig.kt @@ -0,0 +1,17 @@ +package org.wordpress.android.util.config + +import org.wordpress.android.annotation.RemoteFieldDefaultGenerater +import javax.inject.Inject + +const val IN_APP_UPDATE_BLOCKING_VERSION_DEFAULT = "0" + +@RemoteFieldDefaultGenerater( + remoteField = IN_APP_UPDATE_BLOCKING_VERSION_REMOTE_FIELD, + defaultValue = IN_APP_UPDATE_BLOCKING_VERSION_DEFAULT +) + +class InAppUpdateBlockingVersionConfig @Inject constructor(appConfig: AppConfig) : + RemoteConfigField( + appConfig, + IN_APP_UPDATE_BLOCKING_VERSION_REMOTE_FIELD + ) diff --git a/WordPress/src/main/java/org/wordpress/android/util/config/InAppUpdateFlexibleIntervalConfig.kt b/WordPress/src/main/java/org/wordpress/android/util/config/InAppUpdateFlexibleIntervalConfig.kt new file mode 100644 index 000000000000..17541cc9dfea --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/config/InAppUpdateFlexibleIntervalConfig.kt @@ -0,0 +1,18 @@ +package org.wordpress.android.util.config + +import org.wordpress.android.annotation.RemoteFieldDefaultGenerater +import javax.inject.Inject + +const val IN_APP_UPDATE_FLEXIBLE_INTERVAL_REMOTE_FIELD = "in_app_update_flexible_interval_in_days_android" +const val IN_APP_UPDATE_FLEXIBLE_INTERVAL_DEFAULT = "5" + +@RemoteFieldDefaultGenerater( + remoteField = IN_APP_UPDATE_FLEXIBLE_INTERVAL_REMOTE_FIELD, + defaultValue = IN_APP_UPDATE_FLEXIBLE_INTERVAL_DEFAULT +) + +class InAppUpdateFlexibleIntervalConfig @Inject constructor(appConfig: AppConfig) : + RemoteConfigField( + appConfig, + IN_APP_UPDATE_FLEXIBLE_INTERVAL_REMOTE_FIELD + ) diff --git a/WordPress/src/main/java/org/wordpress/android/util/config/InAppUpdatesFeatureConfig.kt b/WordPress/src/main/java/org/wordpress/android/util/config/InAppUpdatesFeatureConfig.kt new file mode 100644 index 000000000000..3836cb91ff64 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/config/InAppUpdatesFeatureConfig.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.util.config + +import org.wordpress.android.BuildConfig +import org.wordpress.android.annotation.Feature +import javax.inject.Inject + +private const val IN_APP_UPDATES_FEATURE_REMOTE_FIELD = "in_app_updates" + +@Feature(IN_APP_UPDATES_FEATURE_REMOTE_FIELD, false) +class InAppUpdatesFeatureConfig @Inject constructor( + appConfig: AppConfig +) : FeatureConfig( + appConfig, + BuildConfig.ENABLE_IN_APP_UPDATES, + IN_APP_UPDATES_FEATURE_REMOTE_FIELD +) diff --git a/WordPress/src/main/java/org/wordpress/android/util/config/RemoteConfigWrapper.kt b/WordPress/src/main/java/org/wordpress/android/util/config/RemoteConfigWrapper.kt index 3113d9b84bfa..eae8223994d6 100644 --- a/WordPress/src/main/java/org/wordpress/android/util/config/RemoteConfigWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/util/config/RemoteConfigWrapper.kt @@ -7,9 +7,13 @@ import javax.inject.Singleton class RemoteConfigWrapper @Inject constructor( private val openWebLinksWithJetpackFlowFrequencyConfig: OpenWebLinksWithJetpackFlowFrequencyConfig, private val codeableGetFreeEstimateUrlConfig: CodeableGetFreeEstimateUrlConfig, - private val performanceMonitoringSampleRateConfig: PerformanceMonitoringSampleRateConfig + private val performanceMonitoringSampleRateConfig: PerformanceMonitoringSampleRateConfig, + private val inAppUpdateBlockingVersionConfig: InAppUpdateBlockingVersionConfig, + private val inAppUpdateFlexibleIntervalConfig: InAppUpdateFlexibleIntervalConfig, ) { fun getOpenWebLinksWithJetpackFlowFrequency() = openWebLinksWithJetpackFlowFrequencyConfig.getValue() fun getPerformanceMonitoringSampleRate() = performanceMonitoringSampleRateConfig.getValue() fun getCodeableGetFreeEstimateUrl() = codeableGetFreeEstimateUrlConfig.getValue() + fun getInAppUpdateBlockingVersion() = inAppUpdateBlockingVersionConfig.getValue() + fun getInAppUpdateFlexibleIntervalInDays() = inAppUpdateFlexibleIntervalConfig.getValue() } diff --git a/WordPress/src/main/java/org/wordpress/android/util/config/VoiceToContentFeatureConfig.kt b/WordPress/src/main/java/org/wordpress/android/util/config/VoiceToContentFeatureConfig.kt new file mode 100644 index 000000000000..75f5f9099108 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/config/VoiceToContentFeatureConfig.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.util.config + +import org.wordpress.android.BuildConfig +import org.wordpress.android.annotation.Feature +import javax.inject.Inject + +private const val VOICE_TO_CONTENT_REMOTE_FIELD = "voice_to_content" + +@Feature(remoteField = VOICE_TO_CONTENT_REMOTE_FIELD, defaultValue = false) +class VoiceToContentFeatureConfig @Inject constructor( + appConfig: AppConfig +) : FeatureConfig( + appConfig, + BuildConfig.VOICE_TO_CONTENT, + VOICE_TO_CONTENT_REMOTE_FIELD, +) diff --git a/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt b/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt index af5e5e430673..5b53ed60f895 100644 --- a/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt @@ -19,6 +19,7 @@ import org.wordpress.android.fluxc.store.SiteStore import org.wordpress.android.fluxc.store.bloggingprompts.BloggingPromptsStore import org.wordpress.android.modules.UI_THREAD import org.wordpress.android.ui.bloggingprompts.BloggingPromptsSettingsHelper +import org.wordpress.android.ui.debug.preferences.DebugPrefs import org.wordpress.android.ui.main.MainActionListItem import org.wordpress.android.ui.main.MainActionListItem.ActionType import org.wordpress.android.ui.main.MainActionListItem.ActionType.ANSWER_BLOGGING_PROMPT @@ -297,9 +298,12 @@ class WPMainActivityViewModel @Inject constructor( launch { val currentVersionCode = buildConfigWrapper.getAppVersionCode() val previousVersionCode = appPrefsWrapper.lastFeatureAnnouncementAppVersionCode + val alwaysShowAnnouncement = appPrefsWrapper.getDebugBooleanPref( + DebugPrefs.ALWAYS_SHOW_ANNOUNCEMENT.key + ) // only proceed to feature announcement logic if we are upgrading the app - if (previousVersionCode != 0 && previousVersionCode < currentVersionCode) { + if (alwaysShowAnnouncement || previousVersionCode != 0 && previousVersionCode < currentVersionCode) { if (canShowFeatureAnnouncement()) { analyticsTracker.track(Stat.FEATURE_ANNOUNCEMENT_SHOWN_ON_APP_UPGRADE) _onFeatureAnnouncementRequested.call() @@ -337,9 +341,11 @@ class WPMainActivityViewModel @Inject constructor( private suspend fun canShowFeatureAnnouncement(): Boolean { val cachedAnnouncement = featureAnnouncementProvider.getLatestFeatureAnnouncement(true) + val alwaysShowAnnouncement = appPrefsWrapper.getDebugBooleanPref(DebugPrefs.ALWAYS_SHOW_ANNOUNCEMENT.key) return cachedAnnouncement != null && - cachedAnnouncement.canBeDisplayedOnAppUpgrade(buildConfigWrapper.getAppVersionName()) && - appPrefsWrapper.featureAnnouncementShownVersion < cachedAnnouncement.announcementVersion + (alwaysShowAnnouncement || + cachedAnnouncement.canBeDisplayedOnAppUpgrade(buildConfigWrapper.getAppVersionName()) && + appPrefsWrapper.featureAnnouncementShownVersion < cachedAnnouncement.announcementVersion) } private fun getExternalFocusPointInfo(task: QuickStartTask?): List { diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 9ea9be2c7f1c..d6c400baa9ce 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -4819,6 +4819,9 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> There was some trouble with the Security key login Please provide your security key to continue. + Update downloaded. Restart to apply. + Restart + Alternatively, you can flatten the content by ungrouping the block. For this reason, we recommend editing the block using the web editor. For this reason, we recommend editing the block using your web browser. diff --git a/WordPress/src/test/java/org/wordpress/android/inappupdate/InAppUpdateAnalyticsTrackerTest.kt b/WordPress/src/test/java/org/wordpress/android/inappupdate/InAppUpdateAnalyticsTrackerTest.kt new file mode 100644 index 000000000000..12932813e511 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/inappupdate/InAppUpdateAnalyticsTrackerTest.kt @@ -0,0 +1,133 @@ +package org.wordpress.android.inappupdate + +import com.google.android.play.core.install.model.AppUpdateType +import org.assertj.core.api.Assertions +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.mock +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.inappupdate.InAppUpdateAnalyticsTracker.Companion.PROPERTY_UPDATE_TYPE +import org.wordpress.android.inappupdate.InAppUpdateAnalyticsTracker.Companion.UPDATE_TYPE_BLOCKING +import org.wordpress.android.inappupdate.InAppUpdateAnalyticsTracker.Companion.UPDATE_TYPE_FLEXIBLE + +@RunWith(MockitoJUnitRunner::class) +class InAppUpdateAnalyticsTrackerTest { + private val analyticsTracker: AnalyticsTrackerWrapper = mock() + lateinit var tracker: InAppUpdateAnalyticsTracker + + private val flexibleProps = mapOf( + PROPERTY_UPDATE_TYPE to UPDATE_TYPE_FLEXIBLE + ) + private val blockingProps = mapOf( + PROPERTY_UPDATE_TYPE to UPDATE_TYPE_BLOCKING + ) + private val emptyProps = emptyMap() + + @Before + fun setUp() { + tracker = InAppUpdateAnalyticsTracker(analyticsTracker) + } + + @Test + fun `trackUpdateShown tracks flexible update shown`() { + tracker.trackUpdateShown(AppUpdateType.FLEXIBLE) + verifyCorrectEventTracking( + expectedEvent = AnalyticsTracker.Stat.IN_APP_UPDATE_SHOWN, + expectedProps = flexibleProps + ) + } + + @Test + fun `trackUpdateShown tracks immediate update shown`() { + tracker.trackUpdateShown(AppUpdateType.IMMEDIATE) + verifyCorrectEventTracking( + expectedEvent = AnalyticsTracker.Stat.IN_APP_UPDATE_SHOWN, + expectedProps = blockingProps + ) + } + + @Test + fun `trackUpdateShown tracks invalid update shown`() { + tracker.trackUpdateShown(-1) + verifyCorrectEventTracking( + expectedEvent = AnalyticsTracker.Stat.IN_APP_UPDATE_SHOWN, + expectedProps = emptyProps + ) + } + + @Test + fun `trackUpdateAccepted tracks flexible update accepted`() { + tracker.trackUpdateAccepted(AppUpdateType.FLEXIBLE) + verifyCorrectEventTracking( + expectedEvent = AnalyticsTracker.Stat.IN_APP_UPDATE_ACCEPTED, + expectedProps = flexibleProps + ) + } + + @Test + fun `trackUpdateAccepted tracks immediate update accepted`() { + tracker.trackUpdateAccepted(AppUpdateType.IMMEDIATE) + verifyCorrectEventTracking( + expectedEvent = AnalyticsTracker.Stat.IN_APP_UPDATE_ACCEPTED, + expectedProps = blockingProps + ) + } + + @Test + fun `trackUpdateAccepted tracks invalid update accepted`() { + tracker.trackUpdateAccepted(-1) + verifyCorrectEventTracking( + expectedEvent = AnalyticsTracker.Stat.IN_APP_UPDATE_ACCEPTED, + expectedProps = emptyProps + ) + } + + @Test + fun `trackUpdateDismissed tracks flexible update dismissed`() { + tracker.trackUpdateDismissed(AppUpdateType.FLEXIBLE) + verifyCorrectEventTracking( + expectedEvent = AnalyticsTracker.Stat.IN_APP_UPDATE_DISMISSED, + expectedProps = flexibleProps + ) + } + + @Test + fun `trackUpdateDismissed tracks immediate update dismissed`() { + tracker.trackUpdateDismissed(AppUpdateType.IMMEDIATE) + verifyCorrectEventTracking( + expectedEvent = AnalyticsTracker.Stat.IN_APP_UPDATE_DISMISSED, + expectedProps = blockingProps + ) + } + + @Test + fun `trackUpdateDismissed tracks invalid update dismissed`() { + tracker.trackUpdateDismissed(-1) + verifyCorrectEventTracking( + expectedEvent = AnalyticsTracker.Stat.IN_APP_UPDATE_DISMISSED, + expectedProps = emptyProps + ) + } + + private fun mapCaptor() = argumentCaptor>() + private fun verifyCorrectEventTracking( + expectedEvent: AnalyticsTracker.Stat, + expectedProps: Map, + expectedTimes: Int = 1 + ) { + mapCaptor().apply { + verify(analyticsTracker, times(expectedTimes)).track( + eq(expectedEvent), + capture() + ) + Assertions.assertThat(firstValue).isEqualTo(expectedProps) + } + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/inappupdate/InAppUpdateManagerImplTest.kt b/WordPress/src/test/java/org/wordpress/android/inappupdate/InAppUpdateManagerImplTest.kt new file mode 100644 index 000000000000..c54ba84bfd1e --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/inappupdate/InAppUpdateManagerImplTest.kt @@ -0,0 +1,235 @@ +package org.wordpress.android.inappupdate + +import android.app.Activity +import android.content.Context +import android.content.SharedPreferences +import com.google.android.gms.tasks.OnFailureListener +import com.google.android.gms.tasks.OnSuccessListener +import com.google.android.gms.tasks.Task +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.model.AppUpdateType +import com.google.android.play.core.install.model.InstallStatus +import com.google.android.play.core.install.model.UpdateAvailability +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.ArgumentMatchers.anyString +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.`when` +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.verify +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.inappupdate.InAppUpdateManagerImpl.Companion.IMMEDIATE_UPDATE_INTERVAL_IN_MILLIS +import org.wordpress.android.inappupdate.InAppUpdateManagerImpl.Companion.KEY_LAST_APP_UPDATE_CHECK_TIME +import org.wordpress.android.util.BuildConfigWrapper +import org.wordpress.android.util.config.RemoteConfigWrapper + +@RunWith(MockitoJUnitRunner::class) +class InAppUpdateManagerImplTest { + @Mock + lateinit var applicationContext: Context + + @Mock + lateinit var appUpdateManager: AppUpdateManager + + @Mock + lateinit var remoteConfigWrapper: RemoteConfigWrapper + + @Mock + lateinit var buildConfigWrapper: BuildConfigWrapper + + @Mock + lateinit var inAppUpdateAnalyticsTracker: InAppUpdateAnalyticsTracker + + @Mock + lateinit var updateListener: InAppUpdateListener + + @Mock + lateinit var activity: Activity + + @Mock + lateinit var appUpdateInfo: AppUpdateInfo + + @Mock + lateinit var sharedPreferences: SharedPreferences + + @Mock + lateinit var sharedPreferencesEditor: SharedPreferences.Editor + + lateinit var currentTimeProvider: () -> Long + + lateinit var inAppUpdateManager: InAppUpdateManagerImpl + + @Before + fun setUp() { + currentTimeProvider = {1715866314746L} // Thu May 16 2024 13:31:54 UTC + + // Mock SharedPreferences behavior + `when`(applicationContext.getSharedPreferences(anyString(), anyInt())).thenReturn(sharedPreferences) + `when`(sharedPreferences.getInt(anyString(), anyInt())).thenReturn(-1) + `when`(sharedPreferences.edit()).thenReturn(sharedPreferencesEditor) + `when`(sharedPreferencesEditor.putInt(anyString(), anyInt())).thenReturn(sharedPreferencesEditor) + `when`(sharedPreferencesEditor.putLong(anyString(), anyLong())).thenReturn(sharedPreferencesEditor) + + inAppUpdateManager = InAppUpdateManagerImpl( + applicationContext, + appUpdateManager, + remoteConfigWrapper, + buildConfigWrapper, + inAppUpdateAnalyticsTracker, + currentTimeProvider + ) + } + + @Test + fun `checkForAppUpdate when update is not available does not trigger update`() { + // Arrange + val task = mockAppUpdateInfoTask(appUpdateInfo) + `when`(appUpdateManager.appUpdateInfo).thenReturn(task) + `when`(appUpdateInfo.updateAvailability()).thenReturn(UpdateAvailability.UPDATE_NOT_AVAILABLE) + + // Act + inAppUpdateManager.checkForAppUpdate(activity, updateListener) + + // Assert + verify(appUpdateManager.appUpdateInfo).addOnSuccessListener(any()) + verify(appUpdateManager, times(0)).startUpdateFlowForResult( + any(), + any(), + any(), + anyInt() + ) + } + + @Test + fun `checkForAppUpdate when update is downloaded calls update listener`() { + // Arrange + val task = mockAppUpdateInfoTask(appUpdateInfo) + `when`(appUpdateManager.appUpdateInfo).thenReturn(task) + `when`(appUpdateInfo.updateAvailability()).thenReturn(UpdateAvailability.UPDATE_AVAILABLE) + `when`(appUpdateInfo.installStatus()).thenReturn(InstallStatus.DOWNLOADED) + + // Act + inAppUpdateManager.checkForAppUpdate(activity, updateListener) + + // Assert + verify(updateListener).onAppUpdateDownloaded() + } + + @Test + fun `checkForAppUpdate requests immediate update when necessary`() { + // Arrange + val task = mockAppUpdateInfoTask(appUpdateInfo) + `when`(appUpdateManager.appUpdateInfo).thenReturn(task) + `when`(appUpdateInfo.updateAvailability()).thenReturn(UpdateAvailability.UPDATE_AVAILABLE) + `when`(appUpdateInfo.installStatus()).thenReturn(InstallStatus.UNKNOWN) + `when`(buildConfigWrapper.getAppVersionCode()).thenReturn(100) // current version + `when`(remoteConfigWrapper.getInAppUpdateBlockingVersion()).thenReturn(200) // blocking version + val lastCheckTimestamp = currentTimeProvider.invoke() - IMMEDIATE_UPDATE_INTERVAL_IN_MILLIS + `when`(sharedPreferences.getLong( eq(KEY_LAST_APP_UPDATE_CHECK_TIME), anyLong())).thenReturn(lastCheckTimestamp) + + // Act + inAppUpdateManager.checkForAppUpdate(activity, updateListener) + + // Assert + verify(appUpdateManager).startUpdateFlowForResult( + any(), + any(), + any(), + eq(APP_UPDATE_IMMEDIATE_REQUEST_CODE) + ) + } + + @Test + fun `checkForAppUpdate requests flexible update when necessary`() { + // Arrange + val task = mockAppUpdateInfoTask(appUpdateInfo) + `when`(appUpdateManager.appUpdateInfo).thenReturn(task) + `when`(appUpdateInfo.updateAvailability()).thenReturn(UpdateAvailability.UPDATE_AVAILABLE) + `when`(appUpdateInfo.installStatus()).thenReturn(InstallStatus.UNKNOWN) + `when`(buildConfigWrapper.getAppVersionCode()).thenReturn(100) // current version + `when`(remoteConfigWrapper.getInAppUpdateBlockingVersion()).thenReturn(50) // blocking version + `when`(remoteConfigWrapper.getInAppUpdateFlexibleIntervalInDays()).thenReturn(1) + val lastCheckTimestamp = currentTimeProvider.invoke() - 1000*60*60*24 + `when`(sharedPreferences.getLong( eq(KEY_LAST_APP_UPDATE_CHECK_TIME), anyLong())).thenReturn(lastCheckTimestamp) + + // Act + inAppUpdateManager.checkForAppUpdate(activity, updateListener) + + // Assert + verify(appUpdateManager).startUpdateFlowForResult( + any(), + any(), + any(), + eq(APP_UPDATE_FLEXIBLE_REQUEST_CODE) + ) + } + + @Test + fun `checkForAppUpdate handles developer triggered update in progress`() { + // Arrange + val task = mockAppUpdateInfoTask(appUpdateInfo) + `when`(appUpdateManager.appUpdateInfo).thenReturn(task) + `when`(appUpdateInfo.updateAvailability()).thenReturn(UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) + `when`(appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)).thenReturn(true) + `when`(buildConfigWrapper.getAppVersionCode()).thenReturn(100) + `when`(remoteConfigWrapper.getInAppUpdateBlockingVersion()).thenReturn(200) + + // Act + inAppUpdateManager.checkForAppUpdate(activity, updateListener) + + // Assert + verify(appUpdateManager).startUpdateFlowForResult( + eq(appUpdateInfo), + eq(activity), + any(), + eq(APP_UPDATE_IMMEDIATE_REQUEST_CODE) + ) + } + + @Test + fun `checkForAppUpdate handles failure correctly`() { + // Arrange + val task = mockAppUpdateInfoTaskWithFailure() + `when`(appUpdateManager.appUpdateInfo).thenReturn(task) + + // Act + inAppUpdateManager.checkForAppUpdate(activity, updateListener) + + // Assert + verify(appUpdateManager.appUpdateInfo).addOnFailureListener(any()) + } + + // Helper method to mock Task with success + @Suppress("UNCHECKED_CAST") + private fun mockAppUpdateInfoTask(appUpdateInfo: AppUpdateInfo): Task { + val task = mock(Task::class.java) as Task + `when`(task.addOnSuccessListener(any())).thenAnswer { invocation -> + (invocation.arguments[0] as OnSuccessListener).onSuccess(appUpdateInfo) + task + } + `when`(task.addOnFailureListener(any())).thenReturn(task) + return task + } + + // Helper method to mock Task with failure + @Suppress("UNCHECKED_CAST") + private fun mockAppUpdateInfoTaskWithFailure(): Task { + val task = mock(Task::class.java) as Task + `when`(task.addOnFailureListener(any())).thenAnswer { invocation -> + (invocation.arguments[0] as OnFailureListener).onFailure(Exception("Update check failed")) + task + } + `when`(task.addOnSuccessListener(any())).thenReturn(task) + return task + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/debug/preferences/DebugSharedPreferenceFlagsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/debug/preferences/DebugSharedPreferenceFlagsViewModelTest.kt index 7e0d136c874b..15e7168d5e42 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/debug/preferences/DebugSharedPreferenceFlagsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/debug/preferences/DebugSharedPreferenceFlagsViewModelTest.kt @@ -20,10 +20,14 @@ class DebugSharedPreferenceFlagsViewModelTest { @Test fun `WHEN init THEN should load the flags from the prefs`() { whenever(prefsWrapper.getAllPrefs()).thenReturn(mapOf("key" to true)) + DebugPrefs.entries.forEach { + whenever(prefsWrapper.getDebugBooleanPref(it.key, false)).thenReturn(false) + } initViewModel() assertTrue(viewModel.uiStateFlow.value["key"]!!) + assertTrue(viewModel.uiStateFlow.value.size >= 2) } @Test diff --git a/WordPress/src/test/java/org/wordpress/android/ui/sitecreation/previews/SitePreviewViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/sitecreation/previews/SitePreviewViewModelTest.kt index b1ed67de828e..7fba132c2b51 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/sitecreation/previews/SitePreviewViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/sitecreation/previews/SitePreviewViewModelTest.kt @@ -62,7 +62,7 @@ class SitePreviewViewModelTest : BaseUnitTest() { private lateinit var uiStateObserver: Observer @Mock - private lateinit var onOkClickedObserver: Observer + private lateinit var onOkClickedObserver: Observer @Mock private lateinit var preloadPreviewObserver: Observer diff --git a/WordPress/src/wordpress/java/org/wordpress/android/util/config/InAppUpdateBlockingVersionConfigConstants.kt b/WordPress/src/wordpress/java/org/wordpress/android/util/config/InAppUpdateBlockingVersionConfigConstants.kt new file mode 100644 index 000000000000..168bda3e189a --- /dev/null +++ b/WordPress/src/wordpress/java/org/wordpress/android/util/config/InAppUpdateBlockingVersionConfigConstants.kt @@ -0,0 +1,5 @@ +package org.wordpress.android.util.config + +const val IN_APP_UPDATE_BLOCKING_VERSION_REMOTE_FIELD = "wp_in_app_update_blocking_version_android" + + diff --git a/build.gradle b/build.gradle index 9b774d3f6deb..c07a8e74de6a 100644 --- a/build.gradle +++ b/build.gradle @@ -23,7 +23,7 @@ ext { automatticAboutVersion = '1.4.0' automatticRestVersion = '1.0.8' automatticTracksVersion = '5.0.0' - gutenbergMobileVersion = 'v1.118.0' + gutenbergMobileVersion = 'v1.119.0-alpha2' wordPressAztecVersion = 'v2.1.3' wordPressFluxCVersion = '2.79.0' wordPressLoginVersion = '1.15.0' @@ -90,6 +90,7 @@ ext { squareupRetrofitVersion = '2.9.0' uCropVersion = '2.2.9' zendeskVersion = '5.1.2' + googlePlayInAppUpdateVersion = '2.1.0' // react native facebookReactVersion = '0.73.3' diff --git a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java index 1cd7bc461d0c..c154d9cdd62d 100644 --- a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java +++ b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java @@ -1122,7 +1122,12 @@ public enum Stat { RESOLVE_AUTOSAVE_CONFLICT_CONFIRM_TAPPED, RESOLVE_AUTOSAVE_CONFLICT_CANCEL_TAPPED, RESOLVE_AUTOSAVE_CONFLICT_CLOSE_TAPPED, - RESOLVE_AUTOSAVE_CONFLICT_DISMISSED; + RESOLVE_AUTOSAVE_CONFLICT_DISMISSED, + IN_APP_UPDATE_SHOWN, + IN_APP_UPDATE_DISMISSED, + IN_APP_UPDATE_ACCEPTED, + IN_APP_UPDATE_COMPLETED_WITH_APP_RESTART; + /* * Please set the event name in the enum only if the new Stat's name in lower case does not match it. * In that case you also need to add the event in the `AnalyticsTrackerNosaraTest.specialNames` map. diff --git a/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergEditorFragment.java b/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergEditorFragment.java index d4c8c63b0770..c8dc71940370 100644 --- a/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergEditorFragment.java +++ b/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergEditorFragment.java @@ -694,7 +694,8 @@ private ArrayList initOtherMediaImageOptions() { Bundle arguments = getArguments(); FragmentActivity activity = getActivity(); - if (activity == null || arguments == null) { + final Context context = getContext(); + if (activity == null || context == null || arguments == null) { AppLog.e(T.EDITOR, "Failed to initialize other media options because the activity or getArguments() is null"); return otherMediaOptions; @@ -710,13 +711,13 @@ private ArrayList initOtherMediaImageOptions() { String packageName = activity.getApplication().getPackageName(); if (supportStockPhotos) { int stockMediaResourceId = - getResources().getIdentifier("photo_picker_stock_media", "string", packageName); + context.getResources().getIdentifier("photo_picker_stock_media", "string", packageName); otherMediaOptions.add(new MediaOption(MEDIA_SOURCE_STOCK_MEDIA, getString(stockMediaResourceId))); } if (supportsTenor) { int gifMediaResourceId = - getResources().getIdentifier("photo_picker_gif", "string", packageName); + context.getResources().getIdentifier("photo_picker_gif", "string", packageName); otherMediaOptions.add(new MediaOption(GIF_MEDIA, getString(gifMediaResourceId))); } @@ -900,6 +901,10 @@ public void onClick(DialogInterface dialog, int id) { @UiThread public void showFeaturedImageConfirmationDialog(final int mediaId) { + if (isStateSaved()) { + return; + } + GutenbergDialogFragment dialog = new GutenbergDialogFragment(); dialog.initialize( TAG_REPLACE_FEATURED_DIALOG, diff --git a/version.properties b/version.properties index fc27a3d01b26..0a8f8ba72065 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ versionName=24.9-rc-2 -versionCode=1432 \ No newline at end of file +versionCode=1432