Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
73 changes: 72 additions & 1 deletion android/app/src/main/java/net/artsy/app/ArtsyNativeModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -55,6 +81,7 @@ public Map<String, Object> 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;
}

Expand Down Expand Up @@ -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");
}
}

}
117 changes: 110 additions & 7 deletions android/app/src/main/java/net/artsy/app/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,27 +1,44 @@
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
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
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions src/app/NativeModules/ArtsyNativeModule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

outside scope of this pr, but I wonder if there is a way to get errors at type check time or with eslint when we use a native module without a platform check.

}
: 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,
}
2 changes: 2 additions & 0 deletions src/app/Scenes/HomeView/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down
57 changes: 57 additions & 0 deletions src/app/utils/usePromptForUpdate.tsx
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The home screen takes no more than 3 seconds to load - so we can maybe decrease this

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried 5,7, and 10, and the update prompt shows up so quickly that before the homescreen loads 😅 so I went with 15 and was too long, so I ended up with 12 and it was perfect

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oki, thanks.


// 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",
})
}
}
3 changes: 3 additions & 0 deletions src/setupJest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,9 @@ function getNativeModules(): OurNativeModules {
getPushToken: jest.fn(),
clearUserData: jest.fn(),
clearCache: jest.fn(),
checkForAppUpdate: jest.fn(),
completeAppUpdate: jest.fn(),
updateDownloaded: false,
},
}
}
Expand Down