expo-dev-launcher 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/android/build.gradle +1 -1
  3. package/android/src/debug/assets/expo_dev_launcher_android.bundle +22 -21
  4. package/android/src/main/AndroidManifest.xml +0 -1
  5. package/android/src/main/java/expo/modules/devlauncher/helpers/DevLauncherReactUtils.kt +3 -1
  6. package/android/src/main/java/expo/modules/devlauncher/launcher/errors/DevLauncherUncaughtExceptionHandler.kt +6 -9
  7. package/android/src/main/java/expo/modules/devlauncher/logs/DevLauncherRemoteLog.kt +13 -37
  8. package/android/src/main/java/expo/modules/devlauncher/logs/DevLauncherRemoteLogManager.kt +27 -31
  9. package/android/src/react-native-64/expo/modules/devlauncher/rncompatibility/DevLauncherReactNativeHostHandler.kt +5 -0
  10. package/android/src/react-native-65/expo/modules/devlauncher/rncompatibility/DevLauncherReactNativeHostHandler.kt +5 -0
  11. package/android/src/react-native-66/expo/modules/devlauncher/rncompatibility/DevLauncherReactNativeHostHandler.kt +5 -0
  12. package/android/src/react-native-67/expo/modules/devlauncher/rncompatibility/DevLauncherReactNativeHostHandler.kt +5 -0
  13. package/android/src/react-native-69/expo/modules/devlauncher/rncompatibility/DevLauncherReactNativeHostHandler.kt +5 -0
  14. package/bundle/components/AppHeader.tsx +7 -1
  15. package/bundle/components/ScreenContainer.tsx +8 -0
  16. package/bundle/native-modules/DevLauncherAuth.ts +21 -10
  17. package/bundle/screens/BranchesScreen.tsx +18 -15
  18. package/bundle/screens/ExtensionsScreen.tsx +37 -38
  19. package/bundle/screens/ExtensionsStack.tsx +1 -0
  20. package/bundle/screens/HomeScreen.tsx +9 -2
  21. package/bundle/screens/SettingsScreen.tsx +113 -110
  22. package/bundle/screens/UpdatesScreen.tsx +44 -39
  23. package/ios/EXDevLauncherController.m +3 -0
  24. package/ios/EXDevLauncherURLHelper.swift +30 -19
  25. package/ios/Errors/EXDevLauncherUncaughtExceptionHandler.swift +14 -10
  26. package/ios/Logs/EXDevLauncherRemoteLogsManager.swift +39 -34
  27. package/ios/main.jsbundle +22 -21
  28. package/package.json +4 -4
@@ -4,7 +4,6 @@
4
4
  <application>
5
5
  <activity
6
6
  android:name="expo.modules.devlauncher.launcher.DevLauncherActivity"
7
- android:screenOrientation="portrait"
8
7
  android:theme="@style/Theme.DevLauncher.LauncherActivity"
9
8
  android:launchMode="singleTask"
10
9
  android:exported="true"
@@ -9,13 +9,15 @@ import com.facebook.react.bridge.JSBundleLoader
9
9
  import expo.interfaces.devmenu.annotations.ContainsDevMenuExtension
10
10
  import expo.modules.devlauncher.react.DevLauncherDevSupportManagerSwapper
11
11
  import expo.modules.devlauncher.react.DevLauncherInternalSettings
12
+ import okhttp3.HttpUrl
12
13
 
13
14
  fun injectReactInterceptor(
14
15
  context: Context,
15
16
  reactNativeHost: ReactNativeHost,
16
17
  url: Uri
17
18
  ): Boolean {
18
- val debugServerHost = url.host + ":" + url.port
19
+ val port = if (url.port != -1) url.port else HttpUrl.defaultPort(url.scheme)
20
+ val debugServerHost = url.host + ":" + port
19
21
  // We need to remove "/" which is added to begin of the path by the Uri
20
22
  // and the bundle type
21
23
  val appBundleName = if (url.path.isNullOrEmpty()) {
@@ -109,28 +109,25 @@ class DevLauncherUncaughtExceptionHandler(
109
109
  }
110
110
 
111
111
  try {
112
- val url = getLogsUrl()
112
+ val url = getWebSocketUrl()
113
113
  val remoteLogManager = DevLauncherRemoteLogManager(DevLauncherKoinContext.app.koin.get(), url)
114
114
  .apply {
115
115
  deferError("Your app just crashed. See the error below.")
116
116
  deferError(exception)
117
117
  }
118
- remoteLogManager.sendSync()
118
+ remoteLogManager.sendViaWebSocket()
119
119
  } catch (e: Throwable) {
120
120
  Log.e("DevLauncher", "Couldn't send an exception to bundler. $e", e)
121
121
  }
122
122
  }
123
123
 
124
- private fun getLogsUrl(): Uri {
125
- val logsUrlFromManifest = controller.manifest?.getLogUrl()
126
- if (logsUrlFromManifest.isNullOrEmpty()) {
127
- return Uri.parse(logsUrlFromManifest)
128
- }
129
-
124
+ private fun getWebSocketUrl(): Uri {
125
+ // URL structure replicates
126
+ // https://github.com/facebook/react-native/blob/0.69-stable/Libraries/Utilities/HMRClient.js#L164
130
127
  return Uri
131
128
  .parse(controller.appHost.reactInstanceManager.devSupportManager.sourceUrl)
132
129
  .buildUpon()
133
- .path("logs")
130
+ .path("hot")
134
131
  .clearQuery()
135
132
  .build()
136
133
  }
@@ -1,39 +1,27 @@
1
1
  package expo.modules.devlauncher.logs
2
2
 
3
- import com.google.gson.Gson
4
3
  import com.google.gson.GsonBuilder
5
4
  import com.google.gson.annotations.Expose
6
5
 
7
- internal interface DevLauncherRemoteLogBody {
8
- val message: String
9
- val stack: String?
10
-
11
- override fun toString(): String
12
- }
13
-
14
- internal class DevLauncherSimpleRemoteLogBody(override val message: String) : DevLauncherRemoteLogBody {
15
- override val stack: String? = null
16
-
17
- override fun toString(): String = message
18
- }
19
-
20
- internal class DevLauncherExceptionRemoteLogBody(exception: Throwable) : DevLauncherRemoteLogBody {
21
- override val message: String = exception.toString()
22
- override val stack: String = exception.stackTraceToRemoteLogString()
23
-
24
- override fun toString(): String = Gson().toJson(this)
25
- }
26
-
6
+ /**
7
+ * object format comes from
8
+ * https://github.com/facebook/react-native/blob/0.69-stable/Libraries/Utilities/HMRClient.js#L119-L134
9
+ */
27
10
  @Suppress("UNUSED")
28
11
  internal data class DevLauncherRemoteLog(
29
- val logBody: DevLauncherRemoteLogBody,
30
- @Expose val level: String = "error"
12
+ val messages: List<String>,
13
+ @Expose val level: String = "error",
14
+ @Expose val mode: String = "BRIDGE"
31
15
  ) {
16
+ /**
17
+ * `data` is an array whose members are simply concatenated with a space before printing to the
18
+ * console, so we join messages with a newline and send an array consisting of just a single item.
19
+ */
32
20
  @Expose
33
- val includesStack = logBody.stack !== null
21
+ private val data = arrayOf(messages.joinToString("\n"))
34
22
 
35
23
  @Expose
36
- private val body = logBody.toString()
24
+ private val type = "log"
37
25
 
38
26
  fun toJson(): String {
39
27
  return GsonBuilder()
@@ -42,15 +30,3 @@ internal data class DevLauncherRemoteLog(
42
30
  .toJson(this)
43
31
  }
44
32
  }
45
-
46
- internal fun Throwable.stackTraceToRemoteLogString(): String {
47
- val baseTrace = stackTrace.joinToString(separator = "\n") {
48
- it.toString()
49
- }
50
-
51
- cause?.let {
52
- return baseTrace + "\nCaused By ${it.stackTraceToRemoteLogString()}"
53
- }
54
-
55
- return baseTrace
56
- }
@@ -1,49 +1,45 @@
1
1
  package expo.modules.devlauncher.logs
2
2
 
3
3
  import android.net.Uri
4
- import android.os.Build
5
- import expo.modules.devlauncher.helpers.await
6
- import expo.modules.devlauncher.helpers.post
7
- import kotlinx.coroutines.runBlocking
8
- import okhttp3.MediaType
9
4
  import okhttp3.OkHttpClient
10
- import okhttp3.RequestBody
5
+ import okhttp3.Request
6
+ import okhttp3.Response
7
+ import okhttp3.WebSocket
8
+ import okhttp3.WebSocketListener
11
9
 
12
- class DevLauncherRemoteLogManager(private val httpClient: OkHttpClient, private val url: Uri) {
13
- private val batch: MutableList<DevLauncherRemoteLog> = mutableListOf()
10
+ class DevLauncherRemoteLogManager(private val httpClient: OkHttpClient, private val url: Uri) : WebSocketListener() {
11
+ private val batch: MutableList<String> = mutableListOf()
14
12
 
15
13
  fun deferError(throwable: Throwable) {
16
- addToBatch(
17
- DevLauncherRemoteLog(
18
- DevLauncherExceptionRemoteLogBody(throwable)
19
- )
20
- )
14
+ batch.add(throwable.toRemoteLogString())
21
15
  }
22
16
 
23
17
  fun deferError(message: String) {
24
- addToBatch(
25
- DevLauncherRemoteLog(
26
- DevLauncherSimpleRemoteLogBody(message)
27
- )
28
- )
18
+ batch.add(message)
29
19
  }
30
20
 
31
- private fun addToBatch(log: DevLauncherRemoteLog) {
32
- batch.add(log)
21
+ fun sendViaWebSocket() {
22
+ val request = Request.Builder().url(url.toString()).build()
23
+ httpClient.newWebSocket(request, this)
33
24
  }
34
25
 
35
- fun sendSync() = runBlocking {
36
- val content = batch.joinToString(separator = ",") { it.toJson() }
37
- val requestBody = RequestBody.create(MediaType.get("application/json"), "[$content]")
26
+ override fun onOpen(webSocket: WebSocket, response: Response) {
27
+ webSocket.send(DevLauncherRemoteLog(batch).toJson())
28
+ webSocket.close(1000, null)
29
+ batch.clear()
30
+ }
31
+ }
38
32
 
39
- val postRequest = post(
40
- url,
41
- requestBody,
42
- "Device-Id" to Build.ID,
43
- "Device-Name" to Build.DISPLAY
44
- )
45
- postRequest.await(httpClient)
33
+ internal fun Throwable.toRemoteLogString(): String {
34
+ val separator = "\n "
35
+ val baseTrace = stackTrace.joinToString(separator) {
36
+ it.toString()
37
+ }
38
+ val remoteLogString = "$this$separator$baseTrace"
46
39
 
47
- batch.clear()
40
+ cause?.let {
41
+ return "$remoteLogString\nCaused by ${it.toRemoteLogString()}"
48
42
  }
43
+
44
+ return remoteLogString
49
45
  }
@@ -25,6 +25,11 @@ class DevLauncherReactNativeHostHandler(context: Context) : ReactNativeHostHandl
25
25
  val applicationContext = context.applicationContext
26
26
 
27
27
  SoLoader.init(applicationContext, /* native exopackage */ false)
28
+ if (SoLoader.getLibraryPath("libv8android.so") != null) {
29
+ // Assuming V8 overrides the `getJavaScriptExecutorFactory` in the main ReactNativeHost,
30
+ // return null here to use the default value.
31
+ return null
32
+ }
28
33
  if (SoLoader.getLibraryPath("libjsc.so") != null) {
29
34
  return JSCExecutorFactory(applicationContext.packageName, AndroidInfoHelpers.getFriendlyDeviceName())
30
35
  }
@@ -25,6 +25,11 @@ class DevLauncherReactNativeHostHandler(context: Context) : ReactNativeHostHandl
25
25
  val applicationContext = context.applicationContext
26
26
 
27
27
  SoLoader.init(applicationContext, /* native exopackage */ false)
28
+ if (SoLoader.getLibraryPath("libv8android.so") != null) {
29
+ // Assuming V8 overrides the `getJavaScriptExecutorFactory` in the main ReactNativeHost,
30
+ // return null here to use the default value.
31
+ return null
32
+ }
28
33
  if (SoLoader.getLibraryPath("libjsc.so") != null) {
29
34
  return JSCExecutorFactory(applicationContext.packageName, AndroidInfoHelpers.getFriendlyDeviceName())
30
35
  }
@@ -25,6 +25,11 @@ class DevLauncherReactNativeHostHandler(context: Context) : ReactNativeHostHandl
25
25
  val applicationContext = context.applicationContext
26
26
 
27
27
  SoLoader.init(applicationContext, /* native exopackage */ false)
28
+ if (SoLoader.getLibraryPath("libv8android.so") != null) {
29
+ // Assuming V8 overrides the `getJavaScriptExecutorFactory` in the main ReactNativeHost,
30
+ // return null here to use the default value.
31
+ return null
32
+ }
28
33
  if (SoLoader.getLibraryPath("libjsc.so") != null) {
29
34
  return JSCExecutorFactory(applicationContext.packageName, AndroidInfoHelpers.getFriendlyDeviceName())
30
35
  }
@@ -26,6 +26,11 @@ class DevLauncherReactNativeHostHandler(context: Context) : ReactNativeHostHandl
26
26
  val applicationContext = context.applicationContext
27
27
 
28
28
  SoLoader.init(applicationContext, /* native exopackage */ false)
29
+ if (SoLoader.getLibraryPath("libv8android.so") != null) {
30
+ // Assuming V8 overrides the `getJavaScriptExecutorFactory` in the main ReactNativeHost,
31
+ // return null here to use the default value.
32
+ return null
33
+ }
29
34
  if (SoLoader.getLibraryPath("libjsc.so") != null) {
30
35
  return JSCExecutorFactory(applicationContext.packageName, AndroidInfoHelpers.getFriendlyDeviceName())
31
36
  }
@@ -26,6 +26,11 @@ class DevLauncherReactNativeHostHandler(context: Context) : ReactNativeHostHandl
26
26
  val applicationContext = context.applicationContext
27
27
 
28
28
  SoLoader.init(applicationContext, /* native exopackage */ false)
29
+ if (SoLoader.getLibraryPath("libv8android.so") != null) {
30
+ // Assuming V8 overrides the `getJavaScriptExecutorFactory` in the main ReactNativeHost,
31
+ // return null here to use the default value.
32
+ return null
33
+ }
29
34
  if (SoLoader.getLibraryPath("libjsc.so") != null) {
30
35
  return JSCExecutorFactory(applicationContext.packageName, AndroidInfoHelpers.getFriendlyDeviceName())
31
36
  }
@@ -11,6 +11,7 @@ import {
11
11
  scale,
12
12
  } from 'expo-dev-client-components';
13
13
  import * as React from 'react';
14
+ import { useWindowDimensions } from 'react-native';
14
15
 
15
16
  import { SafeAreaTop } from '../components/SafeAreaTop';
16
17
  import { useBuildInfo } from '../providers/BuildInfoProvider';
@@ -18,6 +19,7 @@ import { useUser } from '../providers/UserContextProvider';
18
19
 
19
20
  export function AppHeader({ navigation }) {
20
21
  const buildInfo = useBuildInfo();
22
+ const { width } = useWindowDimensions();
21
23
  const { appName, appIcon } = buildInfo;
22
24
 
23
25
  const { userData, selectedAccount } = useUser();
@@ -31,7 +33,11 @@ export function AppHeader({ navigation }) {
31
33
 
32
34
  return (
33
35
  <>
34
- <View bg="default" pt="small" pb="small">
36
+ <View
37
+ bg="default"
38
+ pt="small"
39
+ pb="small"
40
+ style={{ paddingHorizontal: width > 650 ? scale[14] : 0 }}>
35
41
  <SafeAreaTop />
36
42
  <Row align="center">
37
43
  <Spacer.Horizontal size="medium" />
@@ -0,0 +1,8 @@
1
+ import { scale, View } from 'expo-dev-client-components';
2
+ import * as React from 'react';
3
+ import { useWindowDimensions } from 'react-native';
4
+
5
+ export function ScreenContainer({ children }: { children: React.ReactNode }) {
6
+ const { width } = useWindowDimensions();
7
+ return <View style={{ flex: 1, marginHorizontal: width > 650 ? scale[14] : 0 }}>{children}</View>;
8
+ }
@@ -1,8 +1,16 @@
1
- import { NativeModules, AppState, Linking, Platform } from 'react-native';
1
+ import {
2
+ AppState,
3
+ EmitterSubscription,
4
+ Linking,
5
+ Platform,
6
+ NativeEventSubscription,
7
+ NativeModules,
8
+ } from 'react-native';
2
9
 
3
10
  const DevLauncherAuth = NativeModules.EXDevLauncherAuth;
4
11
 
5
- let redirectHandler: ((event: any) => void) | null = null;
12
+ let appStateSubscription: NativeEventSubscription | null = null;
13
+ let redirectSubscription: EmitterSubscription | null = null;
6
14
  let onWebBrowserCloseAndroid: null | (() => void) = null;
7
15
  let isAppStateAvailable: boolean = AppState.currentState !== null;
8
16
 
@@ -18,20 +26,20 @@ function onAppStateChangeAndroid(state: any) {
18
26
  }
19
27
 
20
28
  function stopWaitingForRedirect() {
21
- if (!redirectHandler) {
29
+ if (!redirectSubscription) {
22
30
  throw new Error(
23
31
  `The WebBrowser auth session is in an invalid state with no redirect handler when one should be set`
24
32
  );
25
33
  }
26
34
 
27
- Linking.removeEventListener('url', redirectHandler);
28
- redirectHandler = null;
35
+ redirectSubscription.remove();
36
+ redirectSubscription = null;
29
37
  }
30
38
 
31
39
  async function openBrowserAndWaitAndroidAsync(startUrl: string): Promise<any> {
32
40
  const appStateChangedToActive = new Promise<void>((resolve) => {
33
41
  onWebBrowserCloseAndroid = resolve;
34
- AppState.addEventListener('change', onAppStateChangeAndroid);
42
+ appStateSubscription = AppState.addEventListener('change', onAppStateChangeAndroid);
35
43
  });
36
44
 
37
45
  let result = { type: 'cancel' };
@@ -43,25 +51,28 @@ async function openBrowserAndWaitAndroidAsync(startUrl: string): Promise<any> {
43
51
  result = { type: 'dismiss' };
44
52
  }
45
53
 
46
- AppState.removeEventListener('change', onAppStateChangeAndroid);
54
+ if (appStateSubscription != null) {
55
+ appStateSubscription.remove();
56
+ appStateSubscription = null;
57
+ }
47
58
  onWebBrowserCloseAndroid = null;
48
59
  return result;
49
60
  }
50
61
 
51
62
  function waitForRedirectAsync(returnUrl: string): Promise<any> {
52
63
  return new Promise((resolve) => {
53
- redirectHandler = (event: any) => {
64
+ const redirectHandler = (event: any) => {
54
65
  if (event.url.startsWith(returnUrl)) {
55
66
  resolve({ url: event.url, type: 'success' });
56
67
  }
57
68
  };
58
69
 
59
- Linking.addEventListener('url', redirectHandler);
70
+ redirectSubscription = Linking.addEventListener('url', redirectHandler);
60
71
  });
61
72
  }
62
73
 
63
74
  async function openAuthSessionPolyfillAsync(startUrl: string, returnUrl: string): Promise<any> {
64
- if (redirectHandler) {
75
+ if (redirectSubscription) {
65
76
  throw new Error(
66
77
  `The WebBrowser's auth session is in an invalid state with a redirect handler set when it should not be`
67
78
  );
@@ -6,6 +6,7 @@ import { BasicButton } from '../components/BasicButton';
6
6
  import { EASBranchRow, EASEmptyBranchRow } from '../components/EASUpdatesRows';
7
7
  import { EmptyBranchesMessage } from '../components/EmptyBranchesMessage';
8
8
  import { FlatList } from '../components/FlatList';
9
+ import { ScreenContainer } from '../components/ScreenContainer';
9
10
  import { getRecentRuntime } from '../functions/getRecentRuntime';
10
11
  import { useOnUpdatePress } from '../hooks/useOnUpdatePress';
11
12
  import { useUpdatesConfig } from '../providers/UpdatesConfigProvider';
@@ -127,21 +128,23 @@ export function BranchesScreen({ navigation }: BranchesScreenProps) {
127
128
  }
128
129
 
129
130
  return (
130
- <View flex="1" px="medium">
131
- <FlatList
132
- isLoading={isLoading}
133
- isRefreshing={isRefreshing}
134
- onRefresh={() => refetch()}
135
- ListHeaderComponent={Header}
136
- extraData={{ length: branches.length, hasNextPage, loadingUpdateId }}
137
- data={branches}
138
- ItemSeparatorComponent={Divider}
139
- renderItem={renderBranch}
140
- keyExtractor={(item) => item?.id}
141
- ListFooterComponent={Footer}
142
- ListEmptyComponent={EmptyList}
143
- />
144
- </View>
131
+ <ScreenContainer>
132
+ <View flex="1" px="medium">
133
+ <FlatList
134
+ isLoading={isLoading}
135
+ isRefreshing={isRefreshing}
136
+ onRefresh={() => refetch()}
137
+ ListHeaderComponent={Header}
138
+ extraData={{ length: branches.length, hasNextPage, loadingUpdateId }}
139
+ data={branches}
140
+ ItemSeparatorComponent={Divider}
141
+ renderItem={renderBranch}
142
+ keyExtractor={(item) => item?.id}
143
+ ListFooterComponent={Footer}
144
+ ListEmptyComponent={EmptyList}
145
+ />
146
+ </View>
147
+ </ScreenContainer>
145
148
  );
146
149
  }
147
150
 
@@ -20,6 +20,7 @@ import { AppHeader } from '../components/AppHeader';
20
20
  import { EASBranchRow, EASEmptyBranchRow } from '../components/EASUpdatesRows';
21
21
  import { EmptyBranchesMessage } from '../components/EmptyBranchesMessage';
22
22
  import { ListButton } from '../components/ListButton';
23
+ import { ScreenContainer } from '../components/ScreenContainer';
23
24
  import { useThrottle } from '../hooks/useDebounce';
24
25
  import { useOnUpdatePress } from '../hooks/useOnUpdatePress';
25
26
  import { useUpdatesConfig } from '../providers/UpdatesConfigProvider';
@@ -72,7 +73,7 @@ export function ExtensionsScreen({ navigation }: ExtensionsScreenProps) {
72
73
  <RefreshControl refreshing={throttledRefreshing} onRefresh={() => refetch()} />
73
74
  ) : null
74
75
  }>
75
- <View flex="1">
76
+ <ScreenContainer>
76
77
  {compatibleExtensions.length === 0 && (
77
78
  <>
78
79
  <Spacer.Vertical size="medium" />
@@ -208,7 +209,7 @@ export function ExtensionsScreen({ navigation }: ExtensionsScreenProps) {
208
209
  <ActivityIndicator />
209
210
  </View>
210
211
  )}
211
- </View>
212
+ </ScreenContainer>
212
213
  </ScrollView>
213
214
  </View>
214
215
  );
@@ -298,43 +299,41 @@ function EASUpdatesPreview({
298
299
 
299
300
  // some compatible branches, possible some empty branches
300
301
  return (
301
- <View>
302
- <View mx="medium">
303
- <View py="small" px="small">
304
- <Heading size="small" color="secondary">
305
- EAS Updates
306
- </Heading>
307
- </View>
308
- {branches?.slice(0, 2).map((branch, index, arr) => {
309
- const isFirst = index === 0;
310
- const isLast = index === arr.length - 1 && branchCount <= 1;
311
- const isLoading = branch.updates[0]?.id === loadingUpdateId;
312
-
313
- return (
314
- <View key={branch.name}>
315
- <EASBranchRow
316
- branch={branch}
317
- isFirst={isFirst}
318
- isLast={isLast}
319
- navigation={navigation}
320
- isLoading={isLoading}
321
- onUpdatePress={onUpdatePress}
322
- />
323
- <Divider />
324
- </View>
325
- );
326
- })}
327
-
328
- {branchCount > 1 && (
329
- <ListButton onPress={onSeeAllBranchesPress} isLast>
330
- <Row>
331
- <Text size="medium">See all branches</Text>
332
- <Spacer.Horizontal />
333
- <ChevronRightIcon />
334
- </Row>
335
- </ListButton>
336
- )}
302
+ <View mx="medium">
303
+ <View py="small" px="small">
304
+ <Heading size="small" color="secondary">
305
+ EAS Updates
306
+ </Heading>
337
307
  </View>
308
+ {branches?.slice(0, 2).map((branch, index, arr) => {
309
+ const isFirst = index === 0;
310
+ const isLast = index === arr.length - 1 && branchCount <= 1;
311
+ const isLoading = branch.updates[0]?.id === loadingUpdateId;
312
+
313
+ return (
314
+ <View key={branch.name}>
315
+ <EASBranchRow
316
+ branch={branch}
317
+ isFirst={isFirst}
318
+ isLast={isLast}
319
+ navigation={navigation}
320
+ isLoading={isLoading}
321
+ onUpdatePress={onUpdatePress}
322
+ />
323
+ <Divider />
324
+ </View>
325
+ );
326
+ })}
327
+
328
+ {branchCount > 1 && (
329
+ <ListButton onPress={onSeeAllBranchesPress} isLast>
330
+ <Row>
331
+ <Text size="medium">See all branches</Text>
332
+ <Spacer.Horizontal />
333
+ <ChevronRightIcon />
334
+ </Row>
335
+ </ListButton>
336
+ )}
338
337
  </View>
339
338
  );
340
339
  }
@@ -1,3 +1,4 @@
1
+ import * as React from 'react'
1
2
  import { createStackNavigator } from '@react-navigation/stack';
2
3
 
3
4
  import { BranchesScreen } from './BranchesScreen';
@@ -17,13 +17,14 @@ import {
17
17
  BranchIcon,
18
18
  } from 'expo-dev-client-components';
19
19
  import * as React from 'react';
20
- import { Animated, ScrollView } from 'react-native';
20
+ import { Animated, ScrollView, useWindowDimensions } from 'react-native';
21
21
 
22
22
  import { AppHeader } from '../components/AppHeader';
23
23
  import { DevServerExplainerModal } from '../components/DevServerExplainerModal';
24
24
  import { useLoadingContainerStyle } from '../components/EASUpdatesRows';
25
25
  import { LoadAppErrorModal } from '../components/LoadAppErrorModal';
26
26
  import { PulseIndicator } from '../components/PulseIndicator';
27
+ import { ScreenContainer } from '../components/ScreenContainer';
27
28
  import { Toasts } from '../components/Toasts';
28
29
  import { UrlDropdown } from '../components/UrlDropdown';
29
30
  import { formatUpdateUrl } from '../functions/formatUpdateUrl';
@@ -114,7 +115,12 @@ export function HomeScreen({
114
115
  return (
115
116
  <View testID="DevLauncherMainScreen">
116
117
  <AppHeader navigation={navigation} />
117
- <ScrollView contentContainerStyle={{ paddingBottom: scale['48'] }}>
118
+ <ScrollView
119
+ style={{}}
120
+ contentContainerStyle={{
121
+ paddingBottom: scale['48'],
122
+ }}>
123
+ <ScreenContainer>
118
124
  {crashReport && (
119
125
  <View px="medium" py="small" mt="small">
120
126
  <Button.ScaleOnPressContainer onPress={onCrashReportPress} bg="default" rounded="large">
@@ -197,6 +203,7 @@ export function HomeScreen({
197
203
 
198
204
  <RecentlyOpenedApps onRecentAppPress={onRecentAppPress} loadingUrl={loadingUrl} />
199
205
  </View>
206
+ </ScreenContainer>
200
207
  </ScrollView>
201
208
  </View>
202
209
  );