Skip to content

Commit bcb3392

Browse files
authored
SystemUI configuration on init, for Android (#4863)
1 parent 43b45ae commit bcb3392

26 files changed

+1284
-133
lines changed

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ the _official_ versions compatibility is provided according to the following:
6666
- **RN `v0.77.x` - `v0.82.x`:** Fully compatible with React Native's ["New Architecture"](https://reactnative.dev/docs/the-new-architecture/landing-page).
6767
Newer RN versions might work with Detox, but they've not been thoroughly tested by the Detox team yet.
6868

69-
7069
Although we do not officially support older React Native versions, we do our best to keep Detox compatible with them.
7170

7271
> In case of a problem with an unsupported version of React Native, please [submit an issue](https://github.com/wix/Detox/issues/new/choose) or write us in our [Discord server](https://discord.gg/CkD5QKheF5) and we will do our best to help out.

detox/detox.d.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,42 @@ declare global {
363363
[prop: string]: unknown;
364364
}
365365

366+
/**
367+
* `minimal`: Configuration for a minimal system UI.
368+
* `genymotion`: Configuration for a Genymotion-equivalent system UI.
369+
*
370+
* Visit https://github.com/wix/Detox/blob/master/detox/src/devices/allocation/drivers/android/utils/systemUICfgPresets.js to learn about
371+
* the specifics of each preset.
372+
*/
373+
type DetoxSystemUIPresetName = 'minimal' | 'genymotion';
374+
type DetoxSystemUI = DetoxSystemUIPresetName | DetoxSystemUIConfig;
375+
376+
interface DetoxSystemUIConfig {
377+
extends?: DetoxSystemUIPresetName;
378+
379+
/**
380+
* Note: For 'hide' to work in Google emulators, need to set `hw.keyboard=yes` in AVD configuration (i.e.
381+
* in manually `config.ini` file or via AVD Manager on Android Studio).
382+
*/
383+
keyboard?: 'hide' | 'show' | null;
384+
touches?: 'hide' | 'show' | null;
385+
pointerLocationBar?: 'hide' | 'show' | null;
386+
/** Note: 2-button mode is not supported in recent Android versions; Detox ignores it to avoid confusion. */
387+
navigationMode?: '3-button' | 'gesture' | null;
388+
statusBar?: DetoxSystemUIStatusBarConfig;
389+
}
390+
391+
interface DetoxSystemUIStatusBarConfig {
392+
notifications?: 'show' | 'hide' | null;
393+
wifiSignal?: 'strong' | 'weak' | 'none' | null;
394+
/** Disclaimer: Some Android versions fail to set the network type (3g, lte, etc.) */
395+
cellSignal?: 'strong' | 'weak' | 'none' | null;
396+
batteryLevel?: 'full' | 'half' | 'low' | null;
397+
charging?: boolean | null;
398+
/** In "hhmm" format (e.g. "1234" for 12:34) */
399+
clock?: string | null;
400+
}
401+
366402
type DetoxBuiltInDeviceConfig =
367403
| DetoxIosSimulatorDriverConfig
368404
| DetoxAttachedAndroidDriverConfig
@@ -378,6 +414,9 @@ declare global {
378414
interface DetoxSharedAndroidDriverConfig {
379415
forceAdbInstall?: boolean;
380416
utilBinaryPaths?: string[];
417+
418+
/** Disclaimer: Some features are not seamlessly supported by all Android versions and vendors. */
419+
systemUI?: DetoxSystemUI;
381420
}
382421

383422
interface DetoxAttachedAndroidDriverConfig extends DetoxSharedAndroidDriverConfig {

detox/src/configuration/composeDeviceConfig.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,97 @@ function composeDeviceConfigFromAliased(opts) {
6060
return { ...deviceConfig };
6161
}
6262

63+
/**
64+
* Validates systemUI configuration
65+
* @param {string | object} systemUI - The systemUI configuration
66+
* @param {string} deviceAlias - The device alias for error reporting
67+
* @param {DetoxConfigErrorComposer} errorComposer - Error composer instance
68+
*/
69+
function validateSystemUIConfig(systemUI, deviceAlias, errorComposer) {
70+
if (_.isString(systemUI)) {
71+
if (!['minimal', 'genymotion'].includes(systemUI)) {
72+
throw errorComposer.malformedDeviceProperty(deviceAlias, 'systemUI');
73+
}
74+
return;
75+
}
76+
77+
if (!_.isObject(systemUI)) {
78+
throw errorComposer.malformedDeviceProperty(deviceAlias, 'systemUI');
79+
}
80+
81+
if (systemUI.extends !== undefined) {
82+
if (!['minimal', 'genymotion'].includes(systemUI.extends)) {
83+
throw errorComposer.malformedDeviceProperty(deviceAlias, 'systemUI');
84+
}
85+
}
86+
87+
if (systemUI.keyboard !== undefined && systemUI.keyboard !== null) {
88+
if (!['hide', 'show'].includes(systemUI.keyboard)) {
89+
throw errorComposer.malformedDeviceProperty(deviceAlias, 'systemUI');
90+
}
91+
}
92+
93+
if (systemUI.touches !== undefined && systemUI.touches !== null) {
94+
if (!['hide', 'show'].includes(systemUI.touches)) {
95+
throw errorComposer.malformedDeviceProperty(deviceAlias, 'systemUI');
96+
}
97+
}
98+
99+
if (systemUI.pointerLocationBar !== undefined && systemUI.pointerLocationBar !== null) {
100+
if (!['hide', 'show'].includes(systemUI.pointerLocationBar)) {
101+
throw errorComposer.malformedDeviceProperty(deviceAlias, 'systemUI');
102+
}
103+
}
104+
105+
if (systemUI.navigationMode !== undefined && systemUI.navigationMode !== null) {
106+
if (!['3-button', 'gesture'].includes(systemUI.navigationMode)) {
107+
throw errorComposer.malformedDeviceProperty(deviceAlias, 'systemUI');
108+
}
109+
}
110+
111+
if (systemUI.statusBar !== undefined && systemUI.statusBar !== null) {
112+
if (!_.isObject(systemUI.statusBar)) {
113+
throw errorComposer.malformedDeviceProperty(deviceAlias, 'systemUI');
114+
}
115+
116+
if (systemUI.statusBar.notifications !== undefined && systemUI.statusBar.notifications !== null) {
117+
if (!['hide', 'show'].includes(systemUI.statusBar.notifications)) {
118+
throw errorComposer.malformedDeviceProperty(deviceAlias, 'systemUI');
119+
}
120+
}
121+
122+
if (systemUI.statusBar.wifiSignal !== undefined && systemUI.statusBar.wifiSignal !== null) {
123+
if (!['weak', 'strong', 'none'].includes(systemUI.statusBar.wifiSignal)) {
124+
throw errorComposer.malformedDeviceProperty(deviceAlias, 'systemUI');
125+
}
126+
}
127+
128+
if (systemUI.statusBar.cellSignal !== undefined && systemUI.statusBar.cellSignal !== null) {
129+
if (!['strong', 'weak', 'none'].includes(systemUI.statusBar.cellSignal)) {
130+
throw errorComposer.malformedDeviceProperty(deviceAlias, 'systemUI');
131+
}
132+
}
133+
134+
if (systemUI.statusBar.batteryLevel !== undefined && systemUI.statusBar.batteryLevel !== null) {
135+
if (!['full', 'half', 'low'].includes(systemUI.statusBar.batteryLevel)) {
136+
throw errorComposer.malformedDeviceProperty(deviceAlias, 'systemUI');
137+
}
138+
}
139+
140+
if (systemUI.statusBar.charging !== undefined && systemUI.statusBar.charging !== null) {
141+
if (!_.isBoolean(systemUI.statusBar.charging)) {
142+
throw errorComposer.malformedDeviceProperty(deviceAlias, 'systemUI');
143+
}
144+
}
145+
146+
if (systemUI.statusBar.clock !== undefined && systemUI.statusBar.clock !== null) {
147+
if (!_.isString(systemUI.statusBar.clock) || !/^\d{2}\d{2}$/.test(systemUI.statusBar.clock)) {
148+
throw errorComposer.malformedDeviceProperty(deviceAlias, 'systemUI');
149+
}
150+
}
151+
}
152+
}
153+
63154
/**
64155
* @param {DetoxConfigErrorComposer} errorComposer
65156
* @param {Detox.DetoxDeviceConfig} deviceConfig
@@ -147,6 +238,14 @@ function validateDeviceConfig({ deviceConfig, errorComposer, deviceAlias }) {
147238
}
148239
}
149240

241+
if (deviceConfig.systemUI !== undefined) {
242+
validateSystemUIConfig(deviceConfig.systemUI, deviceAlias, errorComposer);
243+
244+
if (!deviceConfig.type.match(/^android\.(emulator|genycloud|attached)$/)) {
245+
throw errorComposer.unsupportedDeviceProperty(deviceAlias, 'systemUI');
246+
}
247+
}
248+
150249
if (_.isObject(deviceConfig.device)) {
151250
const expectedProperties = EXPECTED_DEVICE_MATCHER_PROPS[deviceConfig.type];
152251
/* istanbul ignore else */

detox/src/configuration/composeDeviceConfig.test.js

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,5 +684,206 @@ describe('composeDeviceConfig', () => {
684684
});
685685
});
686686

687+
describe('systemUI property validation', () => {
688+
beforeEach(() => {
689+
setConfig('android.emulator', 'aliased');
690+
givenConfigValidationSuccess();
691+
});
692+
693+
describe('valid configurations', () => {
694+
it('should accept string "minimal"', () => {
695+
deviceConfig.systemUI = 'minimal';
696+
expect(compose).not.toThrow();
697+
});
698+
699+
it('should accept string "genymotion"', () => {
700+
deviceConfig.systemUI = 'genymotion';
701+
expect(compose).not.toThrow();
702+
});
703+
704+
it('should accept object with all properties', () => {
705+
deviceConfig.systemUI = {
706+
keyboard: 'hide',
707+
touches: 'show',
708+
pointerLocationBar: 'hide',
709+
navigationMode: '3-button',
710+
statusBar: {
711+
notifications: 'hide',
712+
wifiSignal: 'strong',
713+
cellSignal: 'strong',
714+
networkBar: 'hidden',
715+
batteryLevel: 'full',
716+
charging: true,
717+
clock: '1234',
718+
},
719+
};
720+
expect(compose).not.toThrow();
721+
});
722+
723+
it('should accept object with extends property (minimal)', () => {
724+
deviceConfig.systemUI = {
725+
extends: 'minimal',
726+
navigationMode: 'gesture',
727+
statusBar: {
728+
charging: false,
729+
},
730+
};
731+
expect(compose).not.toThrow();
732+
});
733+
734+
it('should accept object with extends property (genymotion)', () => {
735+
deviceConfig.systemUI = {
736+
extends: 'genymotion',
737+
keyboard: 'show',
738+
statusBar: {
739+
notifications: 'show',
740+
},
741+
};
742+
expect(compose).not.toThrow();
743+
});
744+
745+
it('should accept partial object configuration', () => {
746+
deviceConfig.systemUI = {
747+
keyboard: 'hide',
748+
touches: 'show',
749+
};
750+
expect(compose).not.toThrow();
751+
});
752+
753+
it('should accept empty object', () => {
754+
deviceConfig.systemUI = {};
755+
expect(compose).not.toThrow();
756+
});
757+
758+
it('should accept valid statusBar.clock format', () => {
759+
deviceConfig.systemUI = {
760+
statusBar: {
761+
clock: '1234',
762+
},
763+
};
764+
expect(compose).not.toThrow();
765+
});
766+
767+
it('should accept valid statusBar.cellSignal values', () => {
768+
['strong', 'weak', 'none'].forEach(cellSignal => {
769+
deviceConfig.systemUI = {
770+
statusBar: {
771+
cellSignal,
772+
},
773+
};
774+
expect(compose).not.toThrow();
775+
});
776+
});
777+
});
778+
779+
describe('invalid configurations', () => {
780+
it('should reject non-string, non-object values', () => {
781+
deviceConfig.systemUI = 42;
782+
expect(compose).toThrow("Expected 'minimal', 'genymotion' or an object");
783+
});
784+
785+
it('should reject invalid string values', () => {
786+
deviceConfig.systemUI = 'invalid';
787+
expect(compose).toThrow("Expected 'minimal', 'genymotion' or an object");
788+
});
789+
790+
it('should reject invalid extends value', () => {
791+
deviceConfig.systemUI = { extends: 'invalid' };
792+
expect(compose).toThrow("Expected 'minimal', 'genymotion' or an object");
793+
});
794+
795+
it('should reject invalid keyboard value', () => {
796+
deviceConfig.systemUI = { keyboard: 'invalid' };
797+
expect(compose).toThrow("Expected 'minimal', 'genymotion' or an object");
798+
});
799+
800+
it('should reject invalid touches value', () => {
801+
deviceConfig.systemUI = { touches: 'invalid' };
802+
expect(compose).toThrow("Expected 'minimal', 'genymotion' or an object");
803+
});
804+
805+
it('should reject invalid pointerLocationBar value', () => {
806+
deviceConfig.systemUI = { pointerLocationBar: 'invalid' };
807+
expect(compose).toThrow("Expected 'minimal', 'genymotion' or an object");
808+
});
809+
810+
it('should reject invalid navigationMode value', () => {
811+
deviceConfig.systemUI = { navigationMode: 'invalid' };
812+
expect(compose).toThrow("Expected 'minimal', 'genymotion' or an object");
813+
});
814+
815+
it('should reject non-object statusBar', () => {
816+
deviceConfig.systemUI = { statusBar: 'invalid' };
817+
expect(compose).toThrow("Expected 'minimal', 'genymotion' or an object");
818+
});
819+
820+
it('should reject invalid statusBar.notifications', () => {
821+
deviceConfig.systemUI = { statusBar: { notifications: 'invalid' } };
822+
expect(compose).toThrow("Expected 'minimal', 'genymotion' or an object");
823+
});
824+
825+
it('should reject invalid statusBar.wifiSignal', () => {
826+
deviceConfig.systemUI = { statusBar: { wifiSignal: 'invalid' } };
827+
expect(compose).toThrow("Expected 'minimal', 'genymotion' or an object");
828+
});
829+
830+
it('should reject invalid statusBar.cellSignal', () => {
831+
deviceConfig.systemUI = { statusBar: { cellSignal: 'invalid' } };
832+
expect(compose).toThrow("Expected 'minimal', 'genymotion' or an object");
833+
});
834+
835+
it('should reject invalid statusBar.batteryLevel', () => {
836+
deviceConfig.systemUI = { statusBar: { batteryLevel: 'invalid' } };
837+
expect(compose).toThrow("Expected 'minimal', 'genymotion' or an object");
838+
});
839+
840+
it('should reject invalid statusBar.charging', () => {
841+
deviceConfig.systemUI = { statusBar: { charging: 'invalid' } };
842+
expect(compose).toThrow("Expected 'minimal', 'genymotion' or an object");
843+
});
844+
845+
it('should reject invalid statusBar.clock (non-string)', () => {
846+
deviceConfig.systemUI = { statusBar: { clock: 1234 } };
847+
expect(compose).toThrow("Expected 'minimal', 'genymotion' or an object");
848+
});
849+
850+
it('should reject invalid statusBar.clock (wrong format)', () => {
851+
deviceConfig.systemUI = { statusBar: { clock: 'invalid' } };
852+
expect(compose).toThrow("Expected 'minimal', 'genymotion' or an object");
853+
});
854+
855+
it('should accept statusBar.clock', () => {
856+
deviceConfig.systemUI = { statusBar: { clock: '1234' } };
857+
expect(compose).not.toThrow();
858+
});
859+
});
860+
861+
describe('device type restrictions', () => {
862+
it('should reject systemUI for ios.simulator', () => {
863+
setConfig('ios.simulator', 'aliased');
864+
deviceConfig.systemUI = 'minimal';
865+
expect(compose).toThrow('does not support "systemUI" property');
866+
});
867+
868+
it('should accept systemUI for android.attached', () => {
869+
setConfig('android.attached', 'aliased');
870+
deviceConfig.systemUI = 'minimal';
871+
expect(compose).not.toThrow();
872+
});
873+
874+
it('should accept systemUI for android.emulator', () => {
875+
setConfig('android.emulator', 'aliased');
876+
deviceConfig.systemUI = 'minimal';
877+
expect(compose).not.toThrow();
878+
});
879+
880+
it('should accept systemUI for android.genycloud', () => {
881+
setConfig('android.genycloud', 'aliased');
882+
deviceConfig.systemUI = 'minimal';
883+
expect(compose).not.toThrow();
884+
});
885+
});
886+
});
887+
687888
});
688889
});

detox/src/devices/allocation/DeviceAllocator.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,14 @@ class DeviceAllocator {
5050

5151
/**
5252
* @param {DeviceCookie} cookie
53+
* @param {{ deviceConfig: Detox.DetoxDeviceConfig }} [configs]
5354
* @returns {Promise<DeviceCookie>}
5455
*/
55-
async postAllocate(cookie) {
56+
async postAllocate(cookie, configs) {
5657
const tid = this._ids.get(cookie.id);
5758
return await log.trace.complete({ data: cookie, id: tid }, `post-allocate: ${cookie.id}`, async () => {
5859
const updatedCookie = typeof this._driver.postAllocate === 'function'
59-
? await this._driver.postAllocate(cookie)
60+
? await this._driver.postAllocate(cookie, configs)
6061
: undefined;
6162

6263
return updatedCookie || cookie;

0 commit comments

Comments
 (0)