diff --git a/android/app/build.gradle b/android/app/build.gradle index 9f16543c2d4..409d8a9b54f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -211,6 +211,10 @@ dependencies { implementation 'com.facebook.fresco:webpsupport:2.6.0' implementation 'com.android.support:support-core-utils:24.2.1' + // for Android In App Update + implementation 'com.google.android.play:app-update:2.1.0' + implementation 'com.google.android.play:app-update-ktx:2.1.0' + // Test dependencies testImplementation 'junit:junit:4.13.2' testImplementation 'io.mockk:mockk:1.13.12' diff --git a/android/app/src/main/java/net/artsy/app/ArtsyNativeModule.java b/android/app/src/main/java/net/artsy/app/ArtsyNativeModule.java index 9d224b27151..37e13251787 100644 --- a/android/app/src/main/java/net/artsy/app/ArtsyNativeModule.java +++ b/android/app/src/main/java/net/artsy/app/ArtsyNativeModule.java @@ -18,9 +18,11 @@ import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.modules.core.DeviceEventManagerModule; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.bridge.Arguments; import org.apache.commons.io.FileUtils; - import java.io.File; import java.util.HashMap; import java.util.Map; @@ -29,17 +31,41 @@ public class ArtsyNativeModule extends ReactContextBaseJavaModule { // this is called on application launch by MainApplication#onCreate private static final String LAUNCH_COUNT = "launchCount"; + private static final String TAG = "ArtsyApp"; public static void didLaunch(SharedPreferences prefs) { launchCount = prefs.getInt(LAUNCH_COUNT, 0) + 1; prefs.edit().putInt(LAUNCH_COUNT, launchCount).commit(); } private static Integer launchCount = 0; + // Reference to MainActivity for update functionality + private static MainActivity mainActivity; + public static void setMainActivity(MainActivity activity) { + mainActivity = activity; + } + + // Update state management + private static boolean updateDownloaded = false; + private static ArtsyNativeModule instance = null; + + public static void setUpdateDownloadedState(boolean state) { + updateDownloaded = state; + } + + public static void triggerUpdateDownloadedEvent() { + if (instance != null) { + WritableMap params = Arguments.createMap(); + params.putString("message", "Update downloaded successfully"); + instance.sendEvent(params); + } + } + ReactApplicationContext context; ArtsyNativeModule(ReactApplicationContext reactApplicationContext) { super(reactApplicationContext); context = reactApplicationContext; + instance = this; } @NonNull @@ -55,6 +81,7 @@ public Map getConstants() { constants.put("navigationBarHeight", getNavigationBarSize(this.getReactApplicationContext())); constants.put("gitCommitShortHash", BuildConfig.GITCommitShortHash); constants.put("isBeta", BuildConfig.IS_BETA); + constants.put("updateDownloaded", ArtsyNativeModule.updateDownloaded); return constants; } @@ -211,4 +238,48 @@ public void clearCache(Promise promise) { } } + @ReactMethod + public void checkForAppUpdate(Promise promise) { + if (mainActivity != null) { + mainActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + try { + mainActivity.checkForAppUpdateFromRN(); + promise.resolve(true); + } catch (Exception e) { + promise.reject("UPDATE_CHECK_FAILED", "Failed to check for app update", e); + } + } + }); + } else { + promise.reject("NO_MAIN_ACTIVITY", "MainActivity reference not available"); + } + } + + private void sendEvent(WritableMap params) { + getReactApplicationContext() + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit("onAppUpdateDownloaded", params); + } + + @ReactMethod + public void completeAppUpdate(Promise promise) { + if (mainActivity != null) { + mainActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + try { + mainActivity.completeAppUpdate(); + promise.resolve(true); + } catch (Exception e) { + promise.reject("UPDATE_COMPLETE_FAILED", "Failed to complete app update", e); + } + } + }); + } else { + promise.reject("NO_MAIN_ACTIVITY", "MainActivity reference not available"); + } + } + } diff --git a/android/app/src/main/java/net/artsy/app/MainActivity.kt b/android/app/src/main/java/net/artsy/app/MainActivity.kt index 45249e56bf2..641fe27ed91 100644 --- a/android/app/src/main/java/net/artsy/app/MainActivity.kt +++ b/android/app/src/main/java/net/artsy/app/MainActivity.kt @@ -1,17 +1,11 @@ package net.artsy.app import expo.modules.ReactActivityDelegateWrapper -import android.graphics.Color -import android.os.Build import android.os.Bundle import android.content.pm.ActivityInfo import android.content.res.Configuration -import android.view.View -import android.view.WindowInsets -import android.view.WindowManager import android.content.Intent -import android.net.Uri -import androidx.annotation.Nullable +import android.content.IntentSender import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate import com.zoontek.rnbootsplash.RNBootSplash @@ -19,9 +13,32 @@ import com.dieam.reactnativepushnotification.modules.RNPushNotification import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled import com.facebook.react.defaults.DefaultReactActivityDelegate +import androidx.activity.result.contract.ActivityResultContracts +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +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.UpdateAvailability +import com.google.android.play.core.install.model.InstallStatus +import com.google.android.play.core.install.InstallStateUpdatedListener + import android.util.Log class MainActivity : ReactActivity() { + private val DAYS_FOR_FLEXIBLE_UPDATE = -1 + private val TAG = "ArtsyApp" + private lateinit var appUpdateManager: AppUpdateManager + + private val updateResultLauncher = registerForActivityResult( + ActivityResultContracts.StartIntentSenderForResult() + ) { result -> + if (result.resultCode != RESULT_OK) { + Log.d(TAG, "Update flow failed! Result code: ${result.resultCode}") + } + } + + private lateinit var installStateUpdatedListener: InstallStateUpdatedListener /** * Returns the name of the main component registered from JavaScript. This is @@ -91,6 +108,92 @@ class MainActivity : ReactActivity() { return null } }) + appUpdateManager = AppUpdateManagerFactory.create(this) + + installStateUpdatedListener = InstallStateUpdatedListener { state -> + when (state.installStatus()) { + InstallStatus.DOWNLOADED -> { + notifyReactNativeUpdateDownloaded() + } + InstallStatus.INSTALLED -> { + appUpdateManager.unregisterListener(installStateUpdatedListener) + } + InstallStatus.FAILED -> { + appUpdateManager.unregisterListener(installStateUpdatedListener) + } + } + } + + appUpdateManager.registerListener(installStateUpdatedListener) + + // Register this activity with the native module for RN bridge + ArtsyNativeModule.setMainActivity(this) + } + + override fun onResume() { + super.onResume() + + appUpdateManager + .appUpdateInfo + .addOnSuccessListener { appUpdateInfo -> + if (appUpdateInfo.installStatus() == InstallStatus.DOWNLOADED) { + notifyReactNativeUpdateDownloaded() + } + } + } + + override fun onDestroy() { + super.onDestroy() + appUpdateManager.unregisterListener(installStateUpdatedListener) + } + + fun checkForAppUpdateFromRN() { + checkForAppUpdate() + } + + private fun checkForAppUpdate() { + val appUpdateInfoTask = appUpdateManager.appUpdateInfo + + appUpdateInfoTask.addOnFailureListener { appUpdateInfo -> + Log.d(TAG, "checkForAppUpdate: task failed: ${appUpdateInfo.toString()}") + } + + appUpdateInfoTask.addOnSuccessListener { appUpdateInfo -> + val staleDays = appUpdateInfo.clientVersionStalenessDays() ?: -1 + + if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) { + when { + staleDays >= DAYS_FOR_FLEXIBLE_UPDATE && appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) -> { + startUpdateFlow(appUpdateInfo) + } + else -> { + Log.d(TAG, "Update available but conditions not met for update prompt") + } + } + } + } + } + + private fun startUpdateFlow(appUpdateInfo: AppUpdateInfo,) { + try { + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + updateResultLauncher, + AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build() + ) + } catch (e: IntentSender.SendIntentException) { + e.printStackTrace() + } + } + + private fun notifyReactNativeUpdateDownloaded() { + // Store the update state and trigger event + ArtsyNativeModule.setUpdateDownloadedState(true) + ArtsyNativeModule.triggerUpdateDownloadedEvent() + } + + fun completeAppUpdate() { + appUpdateManager.completeUpdate() } // Basic overriding this class required for braze integration: diff --git a/src/app/NativeModules/ArtsyNativeModule.tsx b/src/app/NativeModules/ArtsyNativeModule.tsx index 18186bca455..b42c0518eb8 100644 --- a/src/app/NativeModules/ArtsyNativeModule.tsx +++ b/src/app/NativeModules/ArtsyNativeModule.tsx @@ -55,4 +55,18 @@ export const ArtsyNativeModule = { Platform.OS === "ios" ? NativeModules.ArtsyNativeModule.isBetaOrDev : (NativeModules.ArtsyNativeModule.getConstants().isBeta as boolean) || __DEV__, + checkForAppUpdate: + Platform.OS === "ios" + ? () => { + console.error("checkForAppUpdate is not supported on iOS") + } + : NativeModules.ArtsyNativeModule.checkForAppUpdate, + updateDownloaded: + Platform.OS === "ios" + ? false + : (NativeModules.ArtsyNativeModule.getConstants().updateDownloaded as boolean), + completeAppUpdate: + Platform.OS === "ios" + ? console.error("completeAppUpdate is unsupported on iOS") + : NativeModules.ArtsyNativeModule.completeAppUpdate, } diff --git a/src/app/Scenes/HomeView/HomeView.tsx b/src/app/Scenes/HomeView/HomeView.tsx index 7f684d25a78..1aa025506f6 100644 --- a/src/app/Scenes/HomeView/HomeView.tsx +++ b/src/app/Scenes/HomeView/HomeView.tsx @@ -33,6 +33,7 @@ import { ProvidePlaceholderContext } from "app/utils/placeholders" import { usePrefetch } from "app/utils/queryPrefetching" import { requestPushNotificationsPermission } from "app/utils/requestPushNotificationsPermission" import { useMaybePromptForReview } from "app/utils/useMaybePromptForReview" +import { usePromptForUpdate } from "app/utils/usePromptForUpdate" import { memo, RefObject, Suspense, useCallback, useEffect, useRef, useState } from "react" import { FlatList, Linking, RefreshControl, StatusBar, ViewToken } from "react-native" import { fetchQuery, graphql, useLazyLoadQuery, usePaginationFragment } from "react-relay" @@ -113,6 +114,7 @@ export const HomeView: React.FC = memo(() => { const tracking = useHomeViewTracking() useMaybePromptForReview({ contextModule: ContextModule.tabBar, contextOwnerType: OwnerType.home }) + usePromptForUpdate() const sections = extractNodes(data?.homeView.sectionsConnection) diff --git a/src/app/utils/usePromptForUpdate.tsx b/src/app/utils/usePromptForUpdate.tsx new file mode 100644 index 00000000000..f7ed4326962 --- /dev/null +++ b/src/app/utils/usePromptForUpdate.tsx @@ -0,0 +1,57 @@ +import { useToast } from "app/Components/Toast/toastHook" +import { ArtsyNativeModule } from "app/NativeModules/ArtsyNativeModule" +import { useEffect } from "react" +import { NativeEventEmitter, NativeModules, Platform } from "react-native" + +/** + * This is used to check for app updates on every app launch. + * Also monitors for downloaded updates and shows restart toast. + */ +export const usePromptForUpdate = () => { + const toast = useToast() + + useEffect(() => { + // Only run on Android + if (Platform.OS !== "android") { + return + } + + const eventEmitter = new NativeEventEmitter(NativeModules.ArtsyNativeModule) + + setTimeout(() => { + // delay prompt until homescreen loads + ArtsyNativeModule.checkForAppUpdate() + }, 12000) + + // Check if an update was already downloaded and show toast + if (ArtsyNativeModule.updateDownloaded) { + showUpdateDownloadedToast() + } + + // Listen for update download events + const eventListener = eventEmitter.addListener("onAppUpdateDownloaded", () => { + showUpdateDownloadedToast() + }) + + return () => { + eventListener.remove() + } + }, []) + + const completeAppUpdate = async () => { + try { + await ArtsyNativeModule.completeAppUpdate() + } catch (error: any) { + console.log("Failed to complete app update:", error) + } + } + + const showUpdateDownloadedToast = () => { + toast.show("Update downloaded. Reload to apply the update.", "bottom", { + backgroundColor: "green100", + cta: "Reload", + onPress: completeAppUpdate, + duration: "superLong", + }) + } +} diff --git a/src/setupJest.tsx b/src/setupJest.tsx index d2a3ae37ba8..349cc56864c 100644 --- a/src/setupJest.tsx +++ b/src/setupJest.tsx @@ -460,6 +460,9 @@ function getNativeModules(): OurNativeModules { getPushToken: jest.fn(), clearUserData: jest.fn(), clearCache: jest.fn(), + checkForAppUpdate: jest.fn(), + completeAppUpdate: jest.fn(), + updateDownloaded: false, }, } }