1// @ts-ignore: uses flow
2import normalizeColor from '@react-native/normalize-color';
3// @ts-ignore
4import Debug from 'debug';
5import { ExpoConfig } from 'expo/config';
6import {
7  ConfigPlugin,
8  createRunOncePlugin,
9  AndroidConfig,
10  withStringsXml,
11  WarningAggregator,
12  withAndroidColors,
13  withAndroidStyles,
14} from 'expo/config-plugins';
15import {
16  NavigationBarVisibility,
17  NavigationBarBehavior,
18  NavigationBarPosition,
19  NavigationBarButtonStyle,
20} from 'expo-navigation-bar';
21
22const debug = Debug('expo:system-navigation-bar:plugin');
23
24const pkg = require('expo-navigation-bar/package.json');
25
26export type Props = {
27  borderColor?: string;
28  backgroundColor?: string | null;
29  barStyle?: NavigationBarButtonStyle | null;
30  visibility?: NavigationBarVisibility;
31  behavior?: NavigationBarBehavior;
32  position?: NavigationBarPosition;
33  legacyVisible?: NonNullable<NonNullable<ExpoConfig['androidNavigationBar']>['visible']>;
34};
35
36// strings.xml keys, this should not change.
37const BORDER_COLOR_KEY = 'expo_navigation_bar_border_color';
38const VISIBILITY_KEY = 'expo_navigation_bar_visibility';
39const POSITION_KEY = 'expo_navigation_bar_position';
40const BEHAVIOR_KEY = 'expo_navigation_bar_behavior';
41const LEGACY_VISIBLE_KEY = 'expo_navigation_bar_legacy_visible';
42
43// styles.xml value
44const NAVIGATION_BAR_COLOR = 'navigationBarColor';
45
46const LEGACY_BAR_STYLE_MAP: Record<
47  NonNullable<NonNullable<ExpoConfig['androidNavigationBar']>['barStyle']>,
48  NavigationBarButtonStyle
49> = {
50  // Match expo-status-bar
51  'dark-content': 'dark',
52  'light-content': 'light',
53};
54
55function convertColorAndroid(input: string): number {
56  let color = normalizeColor(input);
57  if (!color) {
58    throw new Error('Invalid color value: ' + input);
59  }
60  color = ((color << 24) | (color >>> 8)) >>> 0;
61
62  // Android use 32 bit *signed* integer to represent the color
63  // We utilize the fact that bitwise operations in JS also operates on
64  // signed 32 bit integers, so that we can use those to convert from
65  // *unsigned* to *signed* 32bit int that way.
66  return color | 0x0;
67}
68
69export function resolveProps(
70  config: Pick<ExpoConfig, 'androidNavigationBar'>,
71  _props: Props | void
72): Props {
73  let props: Props;
74  if (!_props) {
75    props = {
76      backgroundColor: config.androidNavigationBar?.backgroundColor,
77      barStyle: config.androidNavigationBar?.barStyle
78        ? LEGACY_BAR_STYLE_MAP[config.androidNavigationBar?.barStyle]
79        : undefined,
80      // Resources for:
81      // - sticky-immersive: https://youtu.be/cBi8fjv90E4?t=416 -- https://developer.android.com/training/system-ui/immersive#sticky-immersive
82      // - immersive: https://youtu.be/cBi8fjv90E4?t=168 -- https://developer.android.com/training/system-ui/immersive#immersive
83      // - leanback: https://developer.android.com/training/system-ui/immersive#leanback
84      legacyVisible: config.androidNavigationBar?.visible,
85    };
86    if (props.legacyVisible) {
87      // Using legacyVisible can break the setPositionAsync method:
88      // https://developer.android.com/reference/androidx/core/view/WindowCompat#setDecorFitsSystemWindows(android.view.Window,%20boolean)
89      WarningAggregator.addWarningAndroid(
90        'androidNavigationBar.visible',
91        'property is deprecated in Android 11 (API 30) and will be removed from Expo SDK',
92        'https://expo.fyi/android-navigation-bar-visible-deprecated'
93      );
94    }
95  } else {
96    props = _props;
97  }
98  return props;
99}
100
101/**
102 * Ensure the Expo Go manifest is updated when the project is using config plugin properties instead
103 * of the static values that Expo Go reads from (`androidNavigationBar`).
104 */
105export const withAndroidNavigationBarExpoGoManifest: ConfigPlugin<Props> = (config, props) => {
106  if (!config.androidNavigationBar) {
107    // Remap the config plugin props so Expo Go knows how to apply them.
108    config.androidNavigationBar = {
109      backgroundColor: props.backgroundColor ?? undefined,
110      barStyle: Object.entries(LEGACY_BAR_STYLE_MAP).find(
111        ([, v]) => v === props.barStyle
112      )?.[0] as keyof typeof LEGACY_BAR_STYLE_MAP,
113      visible: props.legacyVisible,
114    };
115  }
116  return config;
117};
118
119const withNavigationBar: ConfigPlugin<Props | void> = (config, _props) => {
120  const props = resolveProps(config, _props);
121
122  config = withAndroidNavigationBarExpoGoManifest(config, props);
123
124  debug('Props:', props);
125
126  // TODO: Add this to expo/config-plugins
127  // Elevate props to a static value on extra so Expo Go can read it.
128  if (!config.extra) {
129    config.extra = {};
130  }
131  config.extra[pkg.name] = props;
132
133  // Use built-in styles instead of Expo custom properties, this makes the project hopefully a bit more predictable for bare users.
134  config = withNavigationBarColors(config, props);
135  config = withNavigationBarStyles(config, props);
136
137  return withStringsXml(config, (config) => {
138    config.modResults = setStrings(config.modResults, props);
139    return config;
140  });
141};
142
143export function setStrings(
144  strings: AndroidConfig.Resources.ResourceXML,
145  {
146    borderColor,
147    visibility,
148    position,
149    behavior,
150    legacyVisible,
151  }: Omit<Props, 'backgroundColor' | 'barStyle'>
152): AndroidConfig.Resources.ResourceXML {
153  const pairs = [
154    [BORDER_COLOR_KEY, borderColor ? convertColorAndroid(borderColor) : null],
155    [VISIBILITY_KEY, visibility],
156    [POSITION_KEY, position],
157    [BEHAVIOR_KEY, behavior],
158    [LEGACY_VISIBLE_KEY, legacyVisible],
159  ] as [string, any][];
160
161  const stringItems: AndroidConfig.Resources.ResourceItemXML[] = [];
162  for (const [key, value] of pairs) {
163    if (value == null) {
164      // Since we're using custom strings, we can remove them for convenience between prebuilds.
165      strings = AndroidConfig.Strings.removeStringItem(key, strings);
166    } else {
167      stringItems.push(
168        AndroidConfig.Resources.buildResourceItem({
169          name: key,
170          value: String(value),
171          translatable: false,
172        })
173      );
174    }
175  }
176
177  return AndroidConfig.Strings.setStringItem(stringItems, strings);
178}
179
180const withNavigationBarColors: ConfigPlugin<Pick<Props, 'backgroundColor'>> = (config, props) => {
181  return withAndroidColors(config, (config) => {
182    config.modResults = setNavigationBarColors(props, config.modResults);
183    return config;
184  });
185};
186
187const withNavigationBarStyles: ConfigPlugin<Pick<Props, 'backgroundColor' | 'barStyle'>> = (
188  config,
189  props
190) => {
191  return withAndroidStyles(config, (config) => {
192    config.modResults = setNavigationBarStyles(props, config.modResults);
193    return config;
194  });
195};
196
197export function setNavigationBarColors(
198  { backgroundColor }: Pick<Props, 'backgroundColor'>,
199  colors: AndroidConfig.Resources.ResourceXML
200): AndroidConfig.Resources.ResourceXML {
201  if (backgroundColor) {
202    colors = AndroidConfig.Colors.setColorItem(
203      AndroidConfig.Resources.buildResourceItem({
204        name: NAVIGATION_BAR_COLOR,
205        value: backgroundColor,
206      }),
207      colors
208    );
209  }
210  return colors;
211}
212
213export function setNavigationBarStyles(
214  { backgroundColor, barStyle }: Pick<Props, 'backgroundColor' | 'barStyle'>,
215  styles: AndroidConfig.Resources.ResourceXML
216): AndroidConfig.Resources.ResourceXML {
217  styles = AndroidConfig.Styles.assignStylesValue(styles, {
218    add: !!backgroundColor,
219    parent: AndroidConfig.Styles.getAppThemeLightNoActionBarGroup(),
220    name: `android:${NAVIGATION_BAR_COLOR}`,
221    value: `@color/${NAVIGATION_BAR_COLOR}`,
222  });
223
224  styles = AndroidConfig.Styles.assignStylesValue(styles, {
225    // Adding means the buttons will be darker to account for a light background color.
226    // `setButtonStyleAsync('dark')` should do the same thing.
227    add: barStyle === 'dark',
228    parent: AndroidConfig.Styles.getAppThemeLightNoActionBarGroup(),
229    name: 'android:windowLightNavigationBar',
230    value: 'true',
231  });
232
233  return styles;
234}
235
236export default createRunOncePlugin(withNavigationBar, pkg.name, pkg.version);
237