This document explains how exactly whitelabelling is set-up for the project and what happens behind the scenes.
In case you're looking for the documentation on how to use it in day-to-day development, please refer to the usage instead.
At the high-level, whitelabelling this React Native app can be split into following categories:
- Configuration management
- Native side - that covers application icon, name, bundle ID, etc.
- JavaScript side - branded components, styles, etc.
There are many approaches described out there that tackle whitelabelling of the React Native apps, but they all try to apply the same tool (flavours/build variants for Android and targets for iOS) to solve all of the above three areas, which I don't think is the right thing to do.
Let's dive into what I propose as the alternative.
NOTE: All of the brand specific stuff (apart from the configuraiton itself) resides in the App/brands
directory.
EnvKey manages app configuration per environment by default. For the purpose of whitelabelling the concept of sub-environments is used.
The sub-environments allow to override only specific config values per product brand and re-use most of the values from the "main" environment configuration.
The "main" configuration represents the default
brand.
When wanting to refer to the specific sub-environment values on your local machine or CI/CD agent, you need to grab appropriate key from the EnvKey app. The values of these keys can only be seen once, at the time of generating them so save them somewhere.
By using sub-environments, we make each server key (value of the ENVKEY
variable) to represent a combination of environment and brand at the same time.
At the moment the only "native" things which are able to be whitelabelled are: application icon, fonts, application name and bundle/package ID. That list could potentially be extended, but there wa sno need so far.
I looked at using iOS targets and Android flavours as means of handling whitelabelling just the native side of things, but I discared these options due to potentially too big overhead of manually maintaining these in a platform specific manner within two distinct IDEs.
The approach here is based on the concept of project templates. You can see there are android_template
and ios_template
directories at the root of the repo, but the actual android
and ios
ones are gitignored. This is because the actual project directories are generated from the templates based on the configuration of your choosing (coming from EnvKey, see above).
Once you have the appropriate ENVKEY
set in your .env
file, you can run the yarn configure-brand
command (which will execute the configure_project.sh script) to generate android
and ios
projects. The script itself does more than just that, but I'll cover that in the below sections.
The below diagram illustrates how it all comes together:
@startuml
skinparam backgroundColor white
card ".env file" as dotenv
rectangle EnvKey
rectangle "ios_template\nfolder" as ios_tpl
rectangle "android_template\nfolder" as andr_tpl
rectangle "brands\nfolder" as brands
agent "configure_brand.sh" as conf_prj
rectangle "android project" as andr_prj
rectangle "ios project" as ios_prj
dotenv --> conf_prj: Provides ENVKEY value
EnvKey --> conf_prj: Provides config
andr_tpl --> conf_prj: Input
ios_tpl --> conf_prj: Input
brands --> conf_prj: Input
conf_prj --> andr_prj: Generates
conf_prj --> ios_prj: Generates
@enduml
Icons (or rather icon sets) must be placed under App/brands/<brand>/nativeAssets/appIcon/<platform>/
.
See any existing brand for an example. You can use the following tools to generate icons for
Android and iOS.
The icon for the given brand and platform is simply copied across to appropriate destinations under the android
and ios
folders as part of the configure_brand.sh script. No magic here ;)
NOTE: With the application icons, there's no fallback location maechanism in place. Icons need to exist separately for each brand.
Each brand can define its own fonts which will be linked to both android and ios projects using React Native's built-in auto linking, but enhanced by using the react-native-asset library.
The location of the fonts is App/brands/<brand>/nativeAssets/fonts
.
The magic here was to make React Native to dynamically select the source folder for these fonts depending on the EnvKey configuration.
It was solved by setting the assetsDir
dynamically in the configure_brand.sh script.
Note that there's a fallback logic if there's no fonts folder for the given brand, the ones from the default
brand will be used.
The actual process of linking/unlinking fonts as required is handled by the configure_project.sh, specifically the npx react-native-asset
command within it. Using the react-native-asset
is great as it means we don't need to maually unlink fonts which may have previously existed in the generated android and ios projects for different brands. It ensures that only fonts required by the currently selected brand will be linked.
The app name and bundle ID are generally pulled from the EnvKey config (via DISPLAY_NAME
and BUNDLE_ID
variables respectively), but there are small differences between Android and iOS.
Android project references the env vars directly from within its build.gradle file.
Since the Android project references the env vars directly, we need to ensure that they are set in the process that runs the android build.
That's why the yarn android
command was modified in the package.json
and the load_env.sh is sourced before the actual react-native run-android
is run.
With the variables being set in build.gradle
we then refer to them in the defaultConfig
section. Setting the application ID is straightforward, the rest of them are added to the manifestPlaceholders
map, which makes it possible to refer to them in the AndroidManifest.xml e.g.:
android:label="${DISPLAY_NAME}"
With iOS, I didn't find a straightforward way to refer to the env vars directly at built time (mostly because it's so focused on its own way of doing things with XCode), so I went with a bit different approach.
I did look at using the react-native-config to handle providing external configuration into the ios project, but it felt like a too heavy tool for just that one limited use-case. We also looked at just mimicing what react-native-config does around being able to refer to external config within XCode, but it turned out that all it does is to copy the config values into XCode specific xcconfig file which felt like an unnecessary layer of abstraction in our case.
I ended up using the PListBuddy utility (I couldn't find a link to official docs, thus linking to a post on Medium). It's used to inject some configuration values into ios project at the time of generating it from the template. What it means is that the values seem static to XCode, but they can change whenever you run yarn configure-brand
.
The configure_brand.sh script injects application name and ID as follows:
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" ios/SkyNativeAppRN/Info.plist
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $DISPLAY_NAME" ios/SkyNativeAppRN/Info.plist
The values of BUNDLE_ID
and DISPLAY_NAME
variables are pulled from EnvKey at the beginning of the script.
Whitelabelling within JS/TS layer is based on the module-resolver Babel plugin and its ability to control how modules will be looked up at the build time.
Additionally, the above module resolver's ability to provide multiple paths as root
locations is leveraged to serve branded components from the default
brand when given component wasn't explicitly overriden for the brand that is being built.
The extract from the babel.config.js for reference:
const paths = require('./scripts/projectPaths');
module.exports = {
presets: [...],
plugins: [
[
'module-resolver',
{
extensions: [...],
root: [`./App/brands/${process.env.APP_BRAND}`, './App/brands/default'],
},
],
...
]
}
Plese refer to this blog post to see in details what that means and how it works in practise.
TL;DR; For anything which is imported with the import
keyword, it can ensure the resulting module/component that gets bundled comes from a specific brand.
The branded JS/TS code resides in App/brands/<brand>/*
.
Please refer to the implementation guidelines to see how this mechanism can be applied to many different cases.
Since the whole mechanism depends on having the env vars set in the process that runs the Metro bundler, you need to ensure they are always there with up to date values.
That's why the yarn start
command was modified in the package.json
and the load_env.sh is sourced before the actual react-native start
is run.
With some of the imports being dynamic, there was an additional reuqirement to help IDEs resolve such imports and not seeing "Unable to resolve module" error on all branded components imports. Luckily, there's a solution to that :)
It's best to use TypeScript as VS Code has native support for TS and is able to "understand" the tsconfig.json
file,
by providing appropriate config in that file, you can have VS Code working seamlessly with the dynamic imports.
Only problem with that is that tsconfig.json
is a static file and a dynamic behaviour was needed, so something like .js
form of that config that can have the dynamic part added to it which would read the env vars (similarly to babel.config.js
).
Since TS community didn't added support for a JS/TS version of the config file yet and probably never will
(see What about tsconfig.js? Nooooooooooooooooooooooooooooo
from 2018 meeting notes),
I ended up using the tsconfig.js library to generate the static tsconfig.json
.
The result is that there is tsconfig.json
file in the .gitignore
list (as it's a generated file) and use tsconfig.js file as a template. Whenever you run yarn configure-brand
, it will regenerate the tsconfig.json
with appropriate path mappings for the given brand as per the below snippet from tsconfig.js
:
paths: {
'*': [
`./App/brands/${process.env.APP_BRAND}/*`,
'./App/brands/default/*',
],
},
As you can see, the same default fallback mechanism exists here as in the babel.config.js
so the "logic" of module resolution in VS Code is exactly the same as what Metro bundler will end up doing.
According to the Editors autocompletion section of babel-plugin-module-resolver
there's a way of making WebStorm and IntelliJ properly resolve branded components by marking them as "resources root", but I haven't tried that one out yet. It seems to have some limitations though and not sure it will work as good as with VS Code.
In order to enable using config values from EnvKey in the JS/TS layer, the transform-inline-environment-variables Babel plugin is used and a list of env vars we'd like it to include is provided as follows (extract from babel.config.js):
module.exports = {
presets: [...],
plugins: [
[
'transform-inline-environment-variables',
{
include: [
'SENTRY_DSN',
'SEGMENT_KEY',
'BASE_API_URL',
...
],
},
],
],
};
Within JS/TS code we can refer to these variables with process.env.SENTRY_DSN
.
TODO