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
7 changes: 5 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,11 @@ android {
versionCode 7
versionName "1.0.0"

buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""

vectorDrawables.useSupportLibrary = true

multiDexEnabled true

buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
buildConfigField "String", "GITCommitShortHash", '"c77772ee1"'
buildConfigField "String", "GITCommitHash", '"c77772ee1f32093109049504f7865f9737ad8474"'
buildConfigField "String", "GITCommitDate", '"2021-01-21T15:08:22+04:00"'
Expand Down Expand Up @@ -217,6 +216,10 @@ dependencies {
implementation 'com.facebook.fresco:animated-webp:3.6.0'
implementation 'com.facebook.fresco:webpsupport:3.6.0'

// 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: 73 additions & 0 deletions android/app/src/main/java/net/artsy/app/ArtsyNativeModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
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 java.io.File;
import java.util.HashMap;
Expand All @@ -19,17 +22,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 @@ -44,6 +71,52 @@ public Map<String, Object> getConstants() {
constants.put("launchCount", ArtsyNativeModule.launchCount);
constants.put("gitCommitShortHash", BuildConfig.GITCommitShortHash);
constants.put("isBeta", BuildConfig.IS_BETA);
constants.put("updateDownloaded", ArtsyNativeModule.updateDownloaded);
return constants;
}

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

}
118 changes: 111 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.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 @@ -51,6 +68,93 @@ class MainActivity : ReactActivity() {
// prevent screen rotation on phones
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}

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 @@ -17,4 +17,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
Copy Markdown
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,
Comment thread
MrSltun marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This calls console.error at import time and assigns its return value (undefined) to completeAppUpdate. ArtsyNativeModule is imported at app startup, so every iOS launch logs "completeAppUpdate is unsupported on iOS" even when the method is never called, and ArtsyNativeModule.completeAppUpdate is undefined rather than a callable no-op. Wrap it in a function like checkForAppUpdate above.

Suggested change
: NativeModules.ArtsyNativeModule.completeAppUpdate,
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 @@ -37,6 +37,7 @@ import { usePrefetch } from "app/utils/queryPrefetching"
import { ExtractNodeType } from "app/utils/relayHelpers"
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,
Expand Down Expand Up @@ -123,6 +124,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 = () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 No test covers this hook. Worth one that mocks ArtsyNativeModule and asserts: on Android the toast shows when updateDownloaded is true and when the onAppUpdateDownloaded event fires, the toast CTA calls completeAppUpdate, and nothing runs on iOS. That would also have caught the completeAppUpdate shim bug flagged in ArtsyNativeModule.tsx.

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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
Member

Choose a reason for hiding this comment

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

oki, thanks.

Comment on lines +21 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 The 12s timer isn't cleared on unmount. If the user leaves Home before it fires, checkForAppUpdate still runs (and the whole flow re-arms every time Home remounts). Keep the id and clear it in the cleanup alongside eventListener.remove():

Suggested change
setTimeout(() => {
// delay prompt until homescreen loads
ArtsyNativeModule.checkForAppUpdate()
}, 12000)
const timeoutId = setTimeout(() => {
// delay prompt until homescreen loads
ArtsyNativeModule.checkForAppUpdate()
}, 12000)

Then add clearTimeout(timeoutId) to the returned cleanup.


// 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 @@ -506,6 +506,9 @@ function getNativeModules(): OurNativeModules {
getPushToken: jest.fn(),
getRecentPushPayloads: jest.fn(),
clearUserData: jest.fn(),
checkForAppUpdate: jest.fn(),
completeAppUpdate: jest.fn(),
updateDownloaded: false,
},
}
}
Expand Down
Loading