Repository: wix/Detox
Pull request #2466 (2020-11-15 13:42:31)

Introduce optimization of genycloud instance allocation
## Description
The 2nd piece of the Genymotion-cloud puzzle (see #2429).
Introduces a more efficient way of allocating cloud instances, as per the description below.

For each initializing driver -- associated with an initializing worker, in order to grab a hold over a cloud instance, one has to:
```
a. Check for a free, already-running instance and acquire it, OR otherwise:
b. Allocate a new instance, namely:
b.1. spin-up a new instance synchronously
b.2. adb-connect to it synchronously
```

Currently, all of `b` is done inside the _worker-synchronized_, locked scope of `DeviceRegistry.allocateDevice()`. That means that the allocation - though performed by workers running concurrently, is actually performed serially; Each worker thus holds back all other workers potentially doing the same job, for as long as the entire allocation process (which can take up to a whole minute!).

This PR therefore replaces `b` with the following adjustment:
```
b.1 acquire lock + spin-up a new instance *asynchronously* + unlock
b.2. wait for instance to be online (polling)
b.3. adb-connect to it synchronously
```

---
While the latter description concludes the general idea of the PR, it does not account for its side-effects.

The biggest side-effect is that unlike existing drivers, the Genymotion cloud one has to rely on the geny-cloud's instance UUID's for device-registry management. Existing drivers use the ADB-name. Namely, instead of having _the_ lockfile looking something like this:
```
['emulator-5554', 'emulator-5556']
```
For genymotion-cloud instances, it would looks like this:
```
['db5c1a74-461c-491c-bdd6-f70338a8f9cd', '77c584f2-5996-4bc6-85cc-e92d7ea3ac97']
```
> Remember that right after instance creation, ADB is not connected yet and hence, there _is_ no ADB name yet...

While this in its own is not a big change (replacing one type of string for another), it does not take into account the currently implicit duality in using ADB-name for a device ID:
1. It is used as the unique ID for device-registry management, as explained.
2. It is also the device name consequently used by the driver when accessing the device via ADB, throughout the session, and the usage is trivial (`deviceId`<=>`adbName`).

So while `#1` is easy to replace (i.e. use instance UUID's instead of ADB-names), it's change invalidates `#2`. Hence a side-effect where some translations have to take place. This boils down to these areas:
1. In the Genycloud layer of the driver (i.e. `GenyCloudDriver` and delegates), the allocation function returns the instance UUID for the device-registry, but ultimately returns back the whole `instance` for a `deviceId` (`uuid` and `adbName` properties included...).
2. In the `AndroidDriver` itself, the interchangeable usage of `deviceId` as an `adbName` is no longer straightforward. Rather, inside every relevant method, the `deviceId` is first explicitly translated to the `adbName`, if needed: The driver now allows for `deviceId` to either be a primitive or an object, but in the latter case - is has to hold an `adbName` property.
3. In the new `GenyCloudDriver`, the same is done whenever `adb` is used (but in a simplified way - `deviceId.adbName`).
4. ~In every access to the **device-registry** in the Genycloud realm - even from the base `AndroidDriver`, it needs to be translated from a device ID (i.e. the whole instance) to its uuid, first. This is done agnostically using a mediation layer in the form of a `DeviceRegistry` wrapper, called `GenyDeviceRegistryWrapper`, which the driver replaces the raw device-registry with.~ I've decided to kill this concept of an adaptor and instead move the responsibility of managing the device disposal (from the device-registry) to the unit the allocates it - namely, remove any sign of it from `AndroidDriver` and switch it to the concrete classes (commit dea182431a8da4412081b999d944b2a491591eb5). That makes these translation more explicit and easy to maintain.


Patch:
diff --git a/detox/ios/Detox.xcodeproj/project.pbxproj b/detox/ios/Detox.xcodeproj/project.pbxproj
index 598eb809b..79a8f7dc6 100644
--- a/detox/ios/Detox.xcodeproj/project.pbxproj
+++ b/detox/ios/Detox.xcodeproj/project.pbxproj
@@ -29,6 +29,8 @@
 		3928EFBF1E475E4C00C19B6E /* user_notification_push_trigger.json in Resources */ = {isa = PBXBuildFile; fileRef = 3928EFBB1E475E4C00C19B6E /* user_notification_push_trigger.json */; };
 		39319D7A1FCA0DA70045BC17 /* COSTouchVisualizer.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 393E66E61FC5F3E90092EE89 /* COSTouchVisualizer.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		393E66ED1FC5F3F40092EE89 /* COSTouchVisualizer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 393E66E61FC5F3E90092EE89 /* COSTouchVisualizer.framework */; };
+		3946CCCF256280A4000A3606 /* NSObject+DetoxActions.h in Headers */ = {isa = PBXBuildFile; fileRef = 3946CCCD256280A4000A3606 /* NSObject+DetoxActions.h */; settings = {ATTRIBUTES = (Private, ); }; };
+		3946CCD0256280A4000A3606 /* NSObject+DetoxActions.m in Sources */ = {isa = PBXBuildFile; fileRef = 3946CCCE256280A4000A3606 /* NSObject+DetoxActions.m */; };
 		3947679C1DBF985400D72256 /* Detox.h in Headers */ = {isa = PBXBuildFile; fileRef = 3947679A1DBF985400D72256 /* Detox.h */; settings = {ATTRIBUTES = (Public, ); }; };
 		394767BE1DBF98A700D72256 /* EarlGreyExtensions.h in Headers */ = {isa = PBXBuildFile; fileRef = 394767B91DBF98A700D72256 /* EarlGreyExtensions.h */; settings = {ATTRIBUTES = (Private, ); }; };
 		394767CD1DBF98D900D72256 /* ReactNativeHeaders.h in Headers */ = {isa = PBXBuildFile; fileRef = 394767C41DBF98D900D72256 /* ReactNativeHeaders.h */; settings = {ATTRIBUTES = (Private, ); }; };
@@ -45,6 +47,8 @@
 		39477C2824D1C92700D87E1C /* LNViewHierarchyDumper.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 39477C2224D1C91B00D87E1C /* LNViewHierarchyDumper.framework */; };
 		39477C2924D1C92700D87E1C /* LNViewHierarchyDumper.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 39477C2224D1C91B00D87E1C /* LNViewHierarchyDumper.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		394D589C1E50B59400556DB9 /* NSBundle+TestsFix.m in Sources */ = {isa = PBXBuildFile; fileRef = 394D589B1E50B59400556DB9 /* NSBundle+TestsFix.m */; };
+		3955025C255DC5FA00EFFFE2 /* NSObject+DetoxUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = 3955025A255DC5FA00EFFFE2 /* NSObject+DetoxUtils.h */; settings = {ATTRIBUTES = (Private, ); }; };
+		3955025D255DC5FA00EFFFE2 /* NSObject+DetoxUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 3955025B255DC5FA00EFFFE2 /* NSObject+DetoxUtils.m */; };
 		396EA67A22E0AE7D0087E4D4 /* ___DTXAddressInfo.h in Headers */ = {isa = PBXBuildFile; fileRef = 396EA67222E0AE7C0087E4D4 /* ___DTXAddressInfo.h */; settings = {ATTRIBUTES = (Private, ); }; };
 		396EA67B22E0AE7D0087E4D4 /* ___DTXAddressInfo.mm in Sources */ = {isa = PBXBuildFile; fileRef = 396EA67922E0AE7C0087E4D4 /* ___DTXAddressInfo.mm */; };
 		3976BF02246AC7DE00AA20C7 /* TimeInterval+DetoxUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3976BF01246AC7DE00AA20C7 /* TimeInterval+DetoxUtils.swift */; };
@@ -57,14 +61,12 @@
 		397CA7A324840449005E8A71 /* NSException+DetoxUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 397CA7A224840449005E8A71 /* NSException+DetoxUtils.swift */; };
 		397EC9B51E7EDE0B00D5F2BB /* EarlGreyStatistics.h in Headers */ = {isa = PBXBuildFile; fileRef = 397EC9B31E7EDE0B00D5F2BB /* EarlGreyStatistics.h */; settings = {ATTRIBUTES = (Private, ); }; };
 		397EC9B61E7EDE0B00D5F2BB /* EarlGreyStatistics.m in Sources */ = {isa = PBXBuildFile; fileRef = 397EC9B41E7EDE0B00D5F2BB /* EarlGreyStatistics.m */; };
-		3980D10E2448B52C004812DD /* UIView+DetoxActions.m in Sources */ = {isa = PBXBuildFile; fileRef = 3980D0FD2448B52A004812DD /* UIView+DetoxActions.m */; };
 		3980D10F2448B52C004812DD /* UIApplication+DTXAdditions.h in Headers */ = {isa = PBXBuildFile; fileRef = 3980D0FE2448B52A004812DD /* UIApplication+DTXAdditions.h */; };
 		3980D1102448B52C004812DD /* DTXSyntheticEvents.m in Sources */ = {isa = PBXBuildFile; fileRef = 3980D0FF2448B52A004812DD /* DTXSyntheticEvents.m */; };
-		3980D1112448B52C004812DD /* UIView+DontCrash.m in Sources */ = {isa = PBXBuildFile; fileRef = 3980D1002448B52B004812DD /* UIView+DontCrash.m */; };
+		3980D1112448B52C004812DD /* NSObject+DontCrash.m in Sources */ = {isa = PBXBuildFile; fileRef = 3980D1002448B52B004812DD /* NSObject+DontCrash.m */; };
 		3980D1122448B52C004812DD /* UIApplication+DTXAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = 3980D1012448B52B004812DD /* UIApplication+DTXAdditions.m */; };
-		3980D1132448B52C004812DD /* UIView+DontCrash.h in Headers */ = {isa = PBXBuildFile; fileRef = 3980D1022448B52B004812DD /* UIView+DontCrash.h */; };
+		3980D1132448B52C004812DD /* NSObject+DontCrash.h in Headers */ = {isa = PBXBuildFile; fileRef = 3980D1022448B52B004812DD /* NSObject+DontCrash.h */; };
 		3980D1142448B52C004812DD /* DTXTouchInjector.h in Headers */ = {isa = PBXBuildFile; fileRef = 3980D1032448B52B004812DD /* DTXTouchInjector.h */; };
-		3980D1152448B52C004812DD /* UIView+DetoxActions.h in Headers */ = {isa = PBXBuildFile; fileRef = 3980D1042448B52B004812DD /* UIView+DetoxActions.h */; settings = {ATTRIBUTES = (Private, ); }; };
 		3980D1162448B52C004812DD /* DTXTouchInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = 3980D1052448B52B004812DD /* DTXTouchInfo.m */; };
 		3980D1172448B52C004812DD /* DTXRunLoopSpinner.h in Headers */ = {isa = PBXBuildFile; fileRef = 3980D1062448B52B004812DD /* DTXRunLoopSpinner.h */; settings = {ATTRIBUTES = (Private, ); }; };
 		3980D1182448B52C004812DD /* UITouch+DTXAdditions.h in Headers */ = {isa = PBXBuildFile; fileRef = 3980D1072448B52B004812DD /* UITouch+DTXAdditions.h */; };
@@ -271,6 +273,8 @@
 		3928EFBA1E475E4C00C19B6E /* user_notification_calendar_trigger.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = user_notification_calendar_trigger.json; sourceTree = "<group>"; };
 		3928EFBB1E475E4C00C19B6E /* user_notification_push_trigger.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = user_notification_push_trigger.json; sourceTree = "<group>"; };
 		393E66E11FC5F3E90092EE89 /* COSTouchVisualizer.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = COSTouchVisualizer.xcodeproj; path = COSTouchVisualizer/Classes/COSTouchVisualizer.xcodeproj; sourceTree = "<group>"; };
+		3946CCCD256280A4000A3606 /* NSObject+DetoxActions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSObject+DetoxActions.h"; sourceTree = "<group>"; };
+		3946CCCE256280A4000A3606 /* NSObject+DetoxActions.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSObject+DetoxActions.m"; sourceTree = "<group>"; };
 		394767971DBF985400D72256 /* Detox.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Detox.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		3947679A1DBF985400D72256 /* Detox.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Detox.h; sourceTree = "<group>"; };
 		3947679B1DBF985400D72256 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@@ -289,6 +293,8 @@
 		39477C1C24D1C91B00D87E1C /* LNViewHierarchyDumper.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = LNViewHierarchyDumper.xcodeproj; path = LNViewHierarchyDumper/LNViewHierarchyDumper/LNViewHierarchyDumper.xcodeproj; sourceTree = "<group>"; };
 		394D589A1E50B59400556DB9 /* NSBundle+TestsFix.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSBundle+TestsFix.h"; sourceTree = "<group>"; };
 		394D589B1E50B59400556DB9 /* NSBundle+TestsFix.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSBundle+TestsFix.m"; sourceTree = "<group>"; };
+		3955025A255DC5FA00EFFFE2 /* NSObject+DetoxUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSObject+DetoxUtils.h"; sourceTree = "<group>"; };
+		3955025B255DC5FA00EFFFE2 /* NSObject+DetoxUtils.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSObject+DetoxUtils.m"; sourceTree = "<group>"; };
 		396EA67222E0AE7C0087E4D4 /* ___DTXAddressInfo.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "___DTXAddressInfo.h"; sourceTree = "<group>"; };
 		396EA67922E0AE7C0087E4D4 /* ___DTXAddressInfo.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = "___DTXAddressInfo.mm"; sourceTree = "<group>"; };
 		3975C78220272ED500C59ED8 /* src */ = {isa = PBXFileReference; lastKnownFileType = folder; name = src; path = ../src; sourceTree = "<group>"; };
@@ -302,14 +308,12 @@
 		397CA7A224840449005E8A71 /* NSException+DetoxUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSException+DetoxUtils.swift"; sourceTree = "<group>"; };
 		397EC9B31E7EDE0B00D5F2BB /* EarlGreyStatistics.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = EarlGreyStatistics.h; sourceTree = "<group>"; };
 		397EC9B41E7EDE0B00D5F2BB /* EarlGreyStatistics.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = EarlGreyStatistics.m; sourceTree = "<group>"; };
-		3980D0FD2448B52A004812DD /* UIView+DetoxActions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIView+DetoxActions.m"; sourceTree = "<group>"; };
 		3980D0FE2448B52A004812DD /* UIApplication+DTXAdditions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIApplication+DTXAdditions.h"; sourceTree = "<group>"; };
 		3980D0FF2448B52A004812DD /* DTXSyntheticEvents.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DTXSyntheticEvents.m; sourceTree = "<group>"; };
-		3980D1002448B52B004812DD /* UIView+DontCrash.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIView+DontCrash.m"; sourceTree = "<group>"; };
+		3980D1002448B52B004812DD /* NSObject+DontCrash.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSObject+DontCrash.m"; sourceTree = "<group>"; };
 		3980D1012448B52B004812DD /* UIApplication+DTXAdditions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIApplication+DTXAdditions.m"; sourceTree = "<group>"; };
-		3980D1022448B52B004812DD /* UIView+DontCrash.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIView+DontCrash.h"; sourceTree = "<group>"; };
+		3980D1022448B52B004812DD /* NSObject+DontCrash.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSObject+DontCrash.h"; sourceTree = "<group>"; };
 		3980D1032448B52B004812DD /* DTXTouchInjector.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DTXTouchInjector.h; sourceTree = "<group>"; };
-		3980D1042448B52B004812DD /* UIView+DetoxActions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIView+DetoxActions.h"; sourceTree = "<group>"; };
 		3980D1052448B52B004812DD /* DTXTouchInfo.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DTXTouchInfo.m; sourceTree = "<group>"; };
 		3980D1062448B52B004812DD /* DTXRunLoopSpinner.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DTXRunLoopSpinner.h; sourceTree = "<group>"; };
 		3980D1072448B52B004812DD /* UITouch+DTXAdditions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UITouch+DTXAdditions.h"; sourceTree = "<group>"; };
@@ -656,12 +660,14 @@
 				397CA78B248010B5005E8A71 /* UISlider+DetoxUtils.m */,
 				39FA2BDC250E74210047F419 /* UIImage+DetoxUtils.h */,
 				39FA2BDD250E74210047F419 /* UIImage+DetoxUtils.m */,
+				3955025A255DC5FA00EFFFE2 /* NSObject+DetoxUtils.h */,
+				3955025B255DC5FA00EFFFE2 /* NSObject+DetoxUtils.m */,
 				3990BA132457248600B608C8 /* UIView+DetoxUtils.h */,
 				3990BA142457248600B608C8 /* UIView+DetoxUtils.m */,
 				39E23FF62513AF430035A162 /* UIView+Drawing.h */,
 				39E23FF72513AF430035A162 /* UIView+Drawing.m */,
-				3980D1022448B52B004812DD /* UIView+DontCrash.h */,
-				3980D1002448B52B004812DD /* UIView+DontCrash.m */,
+				3980D1022448B52B004812DD /* NSObject+DontCrash.h */,
+				3980D1002448B52B004812DD /* NSObject+DontCrash.m */,
 				390DED81248906FC00E27BE8 /* UIWindow+DetoxUtils.h */,
 				390DED82248906FC00E27BE8 /* UIWindow+DetoxUtils.m */,
 				392324DE24781CBD00A3D119 /* WebSocket.swift */,
@@ -680,8 +686,8 @@
 				3980D164244DC8F0004812DD /* UIPickerView+DetoxActions.m */,
 				3980D16B244DDCD7004812DD /* UIScrollView+DetoxActions.h */,
 				3980D16C244DDCD7004812DD /* UIScrollView+DetoxActions.m */,
-				3980D1042448B52B004812DD /* UIView+DetoxActions.h */,
-				3980D0FD2448B52A004812DD /* UIView+DetoxActions.m */,
+				3946CCCD256280A4000A3606 /* NSObject+DetoxActions.h */,
+				3946CCCE256280A4000A3606 /* NSObject+DetoxActions.m */,
 			);
 			path = Actions;
 			sourceTree = "<group>";
@@ -746,13 +752,14 @@
 				3980D135244C4373004812DD /* UIView+DetoxMatchers.h in Headers */,
 				3980D16D244DDCD7004812DD /* UIScrollView+DetoxActions.h in Headers */,
 				3980D165244DC8F0004812DD /* UIPickerView+DetoxActions.h in Headers */,
-				3980D1152448B52C004812DD /* UIView+DetoxActions.h in Headers */,
 				39CE25AB22197F0900D78AA1 /* DetoxInstrumentsManager.h in Headers */,
+				3955025C255DC5FA00EFFFE2 /* NSObject+DetoxUtils.h in Headers */,
 				394767BE1DBF98A700D72256 /* EarlGreyExtensions.h in Headers */,
-				3980D1132448B52C004812DD /* UIView+DontCrash.h in Headers */,
+				3980D1132448B52C004812DD /* NSObject+DontCrash.h in Headers */,
 				390DED83248906FC00E27BE8 /* UIWindow+DetoxUtils.h in Headers */,
 				39E7AB3A24F40CDE0011CE29 /* fishhook.h in Headers */,
 				3902CAD7211342D000C49D04 /* WXRNLoadIdlingResource.h in Headers */,
+				3946CCCF256280A4000A3606 /* NSObject+DetoxActions.h in Headers */,
 				3976BF15246C139D00AA20C7 /* DTXSwizzlingHelper.h in Headers */,
 				39E23FF82513AF430035A162 /* UIView+Drawing.h in Headers */,
 				39FA2BDE250E74210047F419 /* UIImage+DetoxUtils.h in Headers */,
@@ -970,7 +977,7 @@
 			buildActionMask = 2147483647;
 			files = (
 				392324E324782AE500A3D119 /* DetoxInit.m in Sources */,
-				3980D1112448B52C004812DD /* UIView+DontCrash.m in Sources */,
+				3980D1112448B52C004812DD /* NSObject+DontCrash.m in Sources */,
 				39E7AB3F24F40DA10011CE29 /* DTXLogging.swift in Sources */,
 				3980D15C244C45EA004812DD /* Action.swift in Sources */,
 				3976BF02246AC7DE00AA20C7 /* TimeInterval+DetoxUtils.swift in Sources */,
@@ -980,12 +987,14 @@
 				468731A61E6C6D0500F151BE /* EarlGrey+Detox.m in Sources */,
 				39BCE599224BDBF000B0D357 /* ReactNativeSupportNoARC.m in Sources */,
 				397EC9B61E7EDE0B00D5F2BB /* EarlGreyStatistics.m in Sources */,
+				3955025D255DC5FA00EFFFE2 /* NSObject+DetoxUtils.m in Sources */,
 				39FA2BDF250E74210047F419 /* UIImage+DetoxUtils.m in Sources */,
 				399BF36921933F0C00F96D50 /* ExternalLogging.m in Sources */,
 				3980D16A244DD837004812DD /* UIDatePicker+DetoxActions.m in Sources */,
 				396EA67B22E0AE7D0087E4D4 /* ___DTXAddressInfo.mm in Sources */,
 				3980D11B2448B52C004812DD /* DTXRunLoopSpinner.m in Sources */,
 				3980D166244DC8F0004812DD /* UIPickerView+DetoxActions.m in Sources */,
+				3946CCD0256280A4000A3606 /* NSObject+DetoxActions.m in Sources */,
 				391FA5EA1E7FD96D0056F82F /* GREYIdlingResourcePrettyPrint.m in Sources */,
 				3990BA162457248600B608C8 /* UIView+DetoxUtils.m in Sources */,
 				397CA7A324840449005E8A71 /* NSException+DetoxUtils.swift in Sources */,
@@ -1009,7 +1018,6 @@
 				397CA79F2483F07D005E8A71 /* ApproximateEquality.swift in Sources */,
 				39E7AB4024F40DA10011CE29 /* DTXLogging.m in Sources */,
 				39CEFCDB1E34E91B00A09124 /* DetoxUserNotificationDispatcher.swift in Sources */,
-				3980D10E2448B52C004812DD /* UIView+DetoxActions.m in Sources */,
 				39FFD9471FD730A600C97030 /* DetoxCrashHandler.mm in Sources */,
 				3980D136244C4373004812DD /* UIView+DetoxMatchers.m in Sources */,
 				392324DF24781CBD00A3D119 /* WebSocket.swift in Sources */,
@@ -1237,9 +1245,11 @@
 			isa = XCBuildConfiguration;
 			baseConfigurationReference = 390D1C981E3A2893007F5F46 /* Detox.xcconfig */;
 			buildSettings = {
+				CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES;
 				CLANG_ENABLE_MODULES = YES;
 				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = NO;
 				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = NO;
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO;
 				CLANG_WARN_UNREACHABLE_CODE = NO;
 				CODE_SIGN_IDENTITY = "";
 				DEAD_CODE_STRIPPING = NO;
@@ -1284,9 +1294,11 @@
 			isa = XCBuildConfiguration;
 			baseConfigurationReference = 390D1C981E3A2893007F5F46 /* Detox.xcconfig */;
 			buildSettings = {
+				CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES;
 				CLANG_ENABLE_MODULES = YES;
 				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = NO;
 				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = NO;
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO;
 				CODE_SIGN_IDENTITY = "";
 				DEAD_CODE_STRIPPING = NO;
 				DEFINES_MODULE = YES;
diff --git a/detox/ios/Detox/Actions/UIView+DetoxActions.h b/detox/ios/Detox/Actions/NSObject+DetoxActions.h
similarity index 83%
rename from detox/ios/Detox/Actions/UIView+DetoxActions.h
rename to detox/ios/Detox/Actions/NSObject+DetoxActions.h
index 03a1d0d44..09e97e904 100644
--- a/detox/ios/Detox/Actions/UIView+DetoxActions.h
+++ b/detox/ios/Detox/Actions/NSObject+DetoxActions.h
@@ -1,18 +1,16 @@
 //
-//  UIView+Detox.h
-//  ExampleApp
+//  NSObject+DetoxActions.h
+//  Detox
 //
-//  Created by Leo Natan (Wix) on 4/16/20.
+//  Created by Leo Natan on 11/16/20.
+//  Copyright © 2020 Wix. All rights reserved.
 //
 
-#import <UIKit/UIKit.h>
+#import <Foundation/Foundation.h>
 
 NS_ASSUME_NONNULL_BEGIN
 
-BOOL DTXClearText(void);
-BOOL DTXTypeText(NSString* text);
-
-@interface UIView (Detox)
+@interface NSObject (DetoxActions)
 
 - (void)dtx_tapAtAccessibilityActivationPoint;
 - (void)dtx_tapAtAccessibilityActivationPointWithNumberOfTaps:(NSUInteger)numberOfTaps;
diff --git a/detox/ios/Detox/Actions/UIView+DetoxActions.m b/detox/ios/Detox/Actions/NSObject+DetoxActions.m
similarity index 83%
rename from detox/ios/Detox/Actions/UIView+DetoxActions.m
rename to detox/ios/Detox/Actions/NSObject+DetoxActions.m
index f88b389b5..d8856175f 100644
--- a/detox/ios/Detox/Actions/UIView+DetoxActions.m
+++ b/detox/ios/Detox/Actions/NSObject+DetoxActions.m
@@ -1,21 +1,21 @@
 //
-//  UIView+Detox.m
-//  ExampleApp
+//  NSObject+DetoxActions.m
+//  Detox
 //
-//  Created by Leo Natan (Wix) on 4/16/20.
+//  Created by Leo Natan on 11/16/20.
+//  Copyright © 2020 Wix. All rights reserved.
 //
 
-#import "UIView+DetoxActions.h"
+#import "NSObject+DetoxActions.h"
+#import "NSObject+DetoxUtils.h"
 
 @import Darwin;
 @import AudioToolbox;
 
 #import "DTXAppleInternals.h"
 #import "DTXSyntheticEvents.h"
-#import "UIView+DetoxUtils.h"
 
-DTX_DIRECT_MEMBERS
-@implementation UIView (Detox)
+@implementation NSObject (DetoxActions)
 
 - (void)dtx_tapAtAccessibilityActivationPoint
 {
@@ -36,15 +36,22 @@ DTX_DIRECT_MEMBERS
 		return;
 	}
 	
-	[self dtx_assertHittableAtPoint:point];
-	
 	NSParameterAssert(numberOfTaps >= 1);
-	point = [self.window convertPoint:point fromView:self];
+	
+	UIView* view = self.dtx_view;
+	UIWindow* window = view.window;
+	CGPoint viewPoint = [self dtx_convertRelativePointToViewCoordinateSpace:point];
+	
+	[view dtx_assertHittableAtPoint:viewPoint];
+	
+	CGPoint windowPoint = [window convertPoint:viewPoint fromView:view];
+	
 	for (NSUInteger idx = 0; idx < numberOfTaps; idx++) {
-		[DTXSyntheticEvents touchAlongPath:@[@(point)] relativeToWindow:self.window holdDurationOnLastTouch:0.0];
+		[DTXSyntheticEvents touchAlongPath:@[@(windowPoint)] relativeToWindow:window holdDurationOnLastTouch:0.0];
 	}
 }
 
+
 - (void)dtx_longPressAtAccessibilityActivationPoint
 {
 	[self dtx_longPressAtAccessibilityActivationPointForDuration:1.0];
@@ -57,16 +64,20 @@ DTX_DIRECT_MEMBERS
 
 - (void)dtx_longPressAtPoint:(CGPoint)point duration:(NSTimeInterval)duration
 {
-	[self dtx_assertHittableAtPoint:point];
+	UIView* view = self.dtx_view;
+	UIWindow* window = view.window;
+	CGPoint viewPoint = [self dtx_convertRelativePointToViewCoordinateSpace:point];
 	
-	point = [self.window convertPoint:point fromView:self];
-	[DTXSyntheticEvents touchAlongPath:@[@(point)] relativeToWindow:self.window holdDurationOnLastTouch:duration];
+	[view dtx_assertHittableAtPoint:viewPoint];
+
+	CGPoint windowPoint = [window convertPoint:viewPoint fromView:view];
+	[DTXSyntheticEvents touchAlongPath:@[@(windowPoint)] relativeToWindow:window holdDurationOnLastTouch:duration];
 }
 
 static void _DTXApplySwipe(UIWindow* window, CGPoint startPoint, CGPoint endPoint, CGFloat velocity)
 {
 	NSCAssert(CGPointEqualToPoint(startPoint, endPoint) == NO, @"Start and end points for swipe cannot be equal");
-
+	
 	NSMutableArray<NSValue*>* points = [NSMutableArray new];
 	
 	for (CGFloat p = 0.0; p <= 1.0; p += 1.0 / (20.0 * velocity))
@@ -76,7 +87,7 @@ static void _DTXApplySwipe(UIWindow* window, CGPoint startPoint, CGPoint endPoin
 		
 		[points addObject:@(CGPointMake(x, y))];
 	}
-
+	
 	[DTXSyntheticEvents touchAlongPath:points relativeToWindow:window holdDurationOnLastTouch:0.0];
 }
 
@@ -99,9 +110,12 @@ endPoint.other = CGRectGetMidOther(safeBoundsInScreenSpace);
 	CGPoint startPoint;
 	CGPoint endPoint;
 	
+	UIWindow* window = self.dtx_view.window;
+	UIView* view = self.dtx_view;
+	
 	CGRect safeBounds = self.dtx_safeAreaBounds;
-	CGRect safeBoundsInScreenSpace = [self.window.screen.coordinateSpace convertRect:safeBounds fromCoordinateSpace:self.coordinateSpace];
-	CGRect screenBounds = self.window.screen.bounds;
+	CGRect safeBoundsInScreenSpace = [window.screen.coordinateSpace convertRect:safeBounds fromCoordinateSpace:view.coordinateSpace];
+	CGRect screenBounds = window.screen.bounds;
 	
 	if(normalizedOffset.x != 0)
 	{
@@ -113,12 +127,12 @@ endPoint.other = CGRectGetMidOther(safeBoundsInScreenSpace);
 	}
 	
 	
-	[self dtx_assertHittableAtPoint:[self.coordinateSpace convertPoint:startPoint fromCoordinateSpace:self.window.screen.coordinateSpace]];
+	[view dtx_assertHittableAtPoint:[view.coordinateSpace convertPoint:startPoint fromCoordinateSpace:window.screen.coordinateSpace]];
 	
-	startPoint = [self.window.coordinateSpace convertPoint:startPoint fromCoordinateSpace:self.window.screen.coordinateSpace];
-	endPoint = [self.window.coordinateSpace convertPoint:endPoint fromCoordinateSpace:self.window.screen.coordinateSpace];
+	startPoint = [window.coordinateSpace convertPoint:startPoint fromCoordinateSpace:window.screen.coordinateSpace];
+	endPoint = [window.coordinateSpace convertPoint:endPoint fromCoordinateSpace:window.screen.coordinateSpace];
 	
-	_DTXApplySwipe(self.window, startPoint, endPoint, 1.0 / velocity);
+	_DTXApplySwipe(window, startPoint, endPoint, 1.0 / velocity);
 }
 
 static void _DTXApplyPinch(UIWindow* window, CGPoint startPoint1, CGPoint endPoint1, CGPoint startPoint2, CGPoint endPoint2, CGFloat velocity)
@@ -138,7 +152,7 @@ static void _DTXApplyPinch(UIWindow* window, CGPoint startPoint1, CGPoint endPoi
 		
 		[points2 addObject:@(CGPointMake(x, y))];
 	}
-
+	
 	[DTXSyntheticEvents touchAlongMultiplePaths:@[points1, points2] relativeToWindow:window holdDurationOnLastTouch:0.0];
 }
 
@@ -190,6 +204,8 @@ static CGFloat clamp(CGFloat v, CGFloat min, CGFloat max)
 		return;
 	}
 	
+	UIView* view = self.dtx_view;
+	UIWindow* window = view.window;
 	CGRect safeBounds = self.dtx_safeAreaBounds;
 	
 	CGPoint startPoint1;
@@ -218,12 +234,12 @@ static CGFloat clamp(CGFloat v, CGFloat min, CGFloat max)
 	[self dtx_assertHittableAtPoint:startPoint1];
 	[self dtx_assertHittableAtPoint:startPoint2];
 	
-	startPoint1 = [self.window convertPoint:startPoint1 fromView:self];
-	endPoint1 = [self.window convertPoint:endPoint1 fromView:self];
-	startPoint2 = [self.window convertPoint:startPoint2 fromView:self];
-	endPoint2 = [self.window convertPoint:endPoint2 fromView:self];
+	startPoint1 = [window convertPoint:startPoint1 fromView:view];
+	endPoint1 = [window convertPoint:endPoint1 fromView:view];
+	startPoint2 = [window convertPoint:startPoint2 fromView:view];
+	endPoint2 = [window convertPoint:endPoint2 fromView:view];
 	
-	_DTXApplyPinch(self.window, startPoint1, endPoint1, startPoint2, endPoint2, 1.0 / velocity);
+	_DTXApplyPinch(window, startPoint1, endPoint1, startPoint2, endPoint2, 1.0 / velocity);
 }
 
 static UIView* _isViewOrDescendantFirstResponder(UIView* view)
@@ -267,7 +283,7 @@ static UIView* _ensureFirstResponderIfNeeded(UIView* view)
 	
 	if(firstResponder == nil)
 	{
-		DTXCViewAssert(firstResponder == nil, firstResponder.dtx_viewDebugAttributes, @"Failed to make view “%@” first responder", view.dtx_shortDescription);
+		DTXCViewAssert(firstResponder == nil, firstResponder.dtx_elementDebugAttributes, @"Failed to make view “%@” first responder", view.dtx_shortDescription);
 	}
 	
 	return firstResponder;
@@ -280,7 +296,7 @@ static BOOL _assertFirstResponderSupportsTextInput(UIView* firstResponder)
 		return YES;
 	}
 	
-	DTXCViewAssert(NO, firstResponder.dtx_viewDebugAttributes, @"First responder “%@” does not conform to “UITextInput” protocol", firstResponder);
+	DTXCViewAssert(NO, firstResponder.dtx_elementDebugAttributes, @"First responder “%@” does not conform to “UITextInput” protocol", firstResponder);
 	
 	return NO;
 }
@@ -326,7 +342,7 @@ static void _DTXFixupKeyboard(void)
 	{
 		[controller setValue:@YES forPreferenceKey:@"DidShowContinuousPathIntroduction"];
 	}
-
+	
 	[controller synchronizePreferences];
 }
 
@@ -343,7 +359,7 @@ static void _DTXTypeText(NSString* text)
 		
 		[UIKeyboardImpl.sharedInstance.taskQueue performTask:^(id ctx) {
 			[UIKeyboardImpl.sharedInstance handleKeyWithString:grapheme forKeyEvent:nil executionContext:ctx];
-					
+			
 			NSArray* sounds = @[@1104, @1155, @1156];
 			
 			AudioServicesPlaySystemSound([sounds[grapheme.hash % 3] unsignedIntValue]);
@@ -361,12 +377,13 @@ static void _DTXTypeText(NSString* text)
 
 - (void)dtx_clearText
 {
-	UIView<UITextInput>* firstResponder = (id)_ensureFirstResponderIfNeeded(self);
+	UIView* view = self.dtx_view;
+	UIView<UITextInput>* firstResponder = (id)_ensureFirstResponderIfNeeded(view);
 	_assertFirstResponderSupportsTextInput(firstResponder);
 	
 	UITextPosition* beginningOfDocument = firstResponder.beginningOfDocument;
 	UITextPosition* endOfDocument = firstResponder.endOfDocument;
-		
+	
 	UITextRange* range = [firstResponder textRangeFromPosition:beginningOfDocument toPosition:endOfDocument];
 	if(range.isEmpty == YES)
 	{
@@ -387,7 +404,8 @@ static void _DTXTypeText(NSString* text)
 
 - (void)dtx_typeText:(NSString*)text atTextRange:(UITextRange*)textRange
 {
-	UIView<UITextInput>* firstResponder = (id)_ensureFirstResponderIfNeeded(self);
+	UIView* view = self.dtx_view;
+	UIView<UITextInput>* firstResponder = (id)_ensureFirstResponderIfNeeded(view);
 	_assertFirstResponderSupportsTextInput(firstResponder);
 	_ensureSelectionAtRange(firstResponder, textRange);
 	
@@ -396,7 +414,8 @@ static void _DTXTypeText(NSString* text)
 
 - (void)dtx_replaceText:(NSString*)text
 {
-	UIView<UITextInput>* firstResponder = (id)_ensureFirstResponderIfNeeded(self);
+	UIView* view = self.dtx_view;
+	UIView<UITextInput>* firstResponder = (id)_ensureFirstResponderIfNeeded(view);
 	_assertFirstResponderSupportsTextInput(firstResponder);
 	
 	BOOL isControl = [firstResponder isKindOfClass:UIControl.class];
@@ -424,7 +443,7 @@ static void _DTXTypeText(NSString* text)
 	
 	UITextPosition* beginningOfDocument = firstResponder.beginningOfDocument;
 	UITextPosition* endOfDocument = firstResponder.endOfDocument;
-		
+	
 	UITextRange* range = [firstResponder textRangeFromPosition:beginningOfDocument toPosition:endOfDocument];
 	
 	[firstResponder replaceRange:range withText:text];
@@ -455,4 +474,3 @@ static void _DTXTypeText(NSString* text)
 }
 
 @end
-
diff --git a/detox/ios/Detox/Actions/UIPickerView+DetoxActions.m b/detox/ios/Detox/Actions/UIPickerView+DetoxActions.m
index 7d55f1793..ebd3dc5cb 100644
--- a/detox/ios/Detox/Actions/UIPickerView+DetoxActions.m
+++ b/detox/ios/Detox/Actions/UIPickerView+DetoxActions.m
@@ -18,7 +18,7 @@
 	
 	NSInteger componentCount = [self.dataSource numberOfComponentsInPickerView:self];
 	
-	DTXViewAssert(componentCount > component, self.dtx_viewDebugAttributes, @"Invalid component “%@” for picker view “%@”", @(component), self.dtx_shortDescription);
+	DTXViewAssert(componentCount > component, self.dtx_elementDebugAttributes, @"Invalid component “%@” for picker view “%@”", @(component), self.dtx_shortDescription);
 
 	NSInteger rowCount = [self.dataSource pickerView:self numberOfRowsInComponent:component];
 
@@ -66,7 +66,7 @@
 		}
 	}
 	
-	DTXViewAssert(NO, self.dtx_viewDebugAttributes, @"Picker view “%@” does not contain value “%@” for component “%@”", self.dtx_shortDescription, value, @(component));
+	DTXViewAssert(NO, self.dtx_elementDebugAttributes, @"Picker view “%@” does not contain value “%@” for component “%@”", self.dtx_shortDescription, value, @(component));
 }
 
 @end
diff --git a/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m b/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m
index b89ade768..2b264b023 100644
--- a/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m
+++ b/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m
@@ -304,7 +304,7 @@ if(isnan(normalizedStartingPoint.main) || normalizedStartingPoint.main < 0 || no
 		}
 	}
 	
-	DTXViewAssert(strict == NO || successfullyAppliedScrolls > 0, self.dtx_viewDebugAttributes, @"Unable to scroll %@ in “%@”", _DTXScrollDirectionDescriptionWithOffset(offset), self.dtx_shortDescription);
+	DTXViewAssert(strict == NO || successfullyAppliedScrolls > 0, self.dtx_elementDebugAttributes, @"Unable to scroll %@ in “%@”", _DTXScrollDirectionDescriptionWithOffset(offset), self.dtx_shortDescription);
 	
 	self.dtx_disableDecelerationForScroll = NO;
 //	self.bounces = oldBounces;
diff --git a/detox/ios/Detox/Detox.modulemap b/detox/ios/Detox/Detox.modulemap
index 5d2ab64c1..1d1a5b94c 100644
--- a/detox/ios/Detox/Detox.modulemap
+++ b/detox/ios/Detox/Detox.modulemap
@@ -4,7 +4,6 @@ framework module Detox {
 
 	explicit module Private {
 		header "UIView+DetoxMatchers.h"
-		header "UIView+DetoxActions.h"
 		header "UIPickerView+DetoxActions.h"
 		header "UIDatePicker+DetoxActions.h"
 		header "UIScrollView+DetoxActions.h"
@@ -21,5 +20,7 @@ framework module Detox {
 		header "DTXDurationFormatter.h"
 		header "UIWindow+DetoxUtils.h"
 		header "DTXAppleInternals.h"
+		header "NSObject+DetoxUtils.h"
+		header "NSObject+DetoxActions.h"
 	}
 }
diff --git a/detox/ios/Detox/Invocation/Action.swift b/detox/ios/Detox/Invocation/Action.swift
index 13cf50303..7e6d5cce3 100644
--- a/detox/ios/Detox/Invocation/Action.swift
+++ b/detox/ios/Detox/Invocation/Action.swift
@@ -163,7 +163,6 @@ class LongPressAction : Action {
 		if let param = params?.first as? Double {
 			duration = param.toSeconds()
 		} else {
-			//TODO: Check default value in Detox
 			duration = 1.0
 		}
 		
diff --git a/detox/ios/Detox/Invocation/Element.swift b/detox/ios/Detox/Invocation/Element.swift
index 0593fd46a..7ba613c9e 100644
--- a/detox/ios/Detox/Invocation/Element.swift
+++ b/detox/ios/Detox/Invocation/Element.swift
@@ -42,28 +42,21 @@ class Element : NSObject {
 		}
 	}
 	
-//	private var cachedViews : [UIView]?
-	private var views : [UIView] {
-//		if let cachedViews = cachedViews {
-//			return cachedViews
-//		}
-		
+	private var views : [NSObject] {
 		//TODO: Consider searching here in all windows from all scenes.
-		let array = (UIView.dtx_findViewsInKeySceneWindows(passing: predicate.predicateForQuery()) as! [UIView])
+		let array = (UIView.dtx_findViewsInKeySceneWindows(passing: predicate.predicateForQuery()) as! [NSObject])
 		
 		guard array.count > 0 else {
 			dtx_fatalError("No elements found for “\(self.description)”", viewDescription: failDebugAttributes)
 		}
 		
-//		cachedViews = array
-		
 		return array
 	}
 	
-	private var view : UIView {
+	private var view : NSObject {
 		let array = self.views
 		
-		let element : UIView
+		let element : NSObject
 		if let index = index {
 			guard index < array.count else {
 				dtx_fatalError("Index \(index) beyond bounds \(array.count > 0 ? "[0 .. \(array.count - 1)] " : " ")for “\(self.description)”", viewDescription: failDebugAttributes)
@@ -111,7 +104,7 @@ class Element : NSObject {
 //			}
 			var rv: [String: Any]! = nil
 			try dtx_try {
-				rv = view.dtx_viewDebugAttributes
+				rv = view.dtx_elementDebugAttributes
 			}
 			return rv
 		} catch {
@@ -199,7 +192,7 @@ class Element : NSObject {
 	
 	func isVisible() throws -> Bool {
 		var error: NSError? = nil
-		let rv = view.dtx_isVisible(at: view.dtx_accessibilityActivationPointInViewCoordinateSpace, error: &error)
+		let rv = view.dtx_isVisible(at: view.dtx_bounds, error: &error)
 		if let error = error {
 			throw error
 		}
diff --git a/detox/ios/Detox/Invocation/Predicate.swift b/detox/ios/Detox/Invocation/Predicate.swift
index 896666268..34c2d0ed4 100644
--- a/detox/ios/Detox/Invocation/Predicate.swift
+++ b/detox/ios/Detox/Invocation/Predicate.swift
@@ -61,19 +61,19 @@ class Predicate : CustomStringConvertible, CustomDebugStringConvertible {
 		case Kind.label:
 			let label = dictionaryRepresentation[Keys.value] as! String
 			if ReactNativeSupport.isReactNativeApp == false {
-				return ValuePredicate(kind: kind, modifiers: modifiers, value: label)
+				return ValuePredicate(kind: kind, modifiers: modifiers, value: label, requiresAccessibilityElement: true)
 			} else {
 				//Will crash if RN app and neither class exists
 				let RCTTextViewClass : AnyClass = NSClassFromString("RCTText") ?? NSClassFromString("RCTTextView")!
 				
 				let descendantPredicate = DescendantPredicate(predicate: AndCompoundPredicate(predicates: [
 					try KindOfPredicate(kind: Kind.type, modifiers: [], className: NSStringFromClass(RCTTextViewClass)),
-					ValuePredicate(kind: kind, modifiers: modifiers, value: label)
+					ValuePredicate(kind: kind, modifiers: modifiers, value: label, requiresAccessibilityElement: true)
 				], modifiers: []), modifiers: [Modifier.not])
 				descendantPredicate.hidden = true
 				
 				return AndCompoundPredicate(predicates: [
-					ValuePredicate(kind: kind, modifiers: modifiers, value: label),
+					ValuePredicate(kind: kind, modifiers: modifiers, value: label, requiresAccessibilityElement: true),
 					descendantPredicate
 				], modifiers: [])
 			}
@@ -96,12 +96,15 @@ class Predicate : CustomStringConvertible, CustomDebugStringConvertible {
 			orCompoundPredicate.hidden = true
 			
 			return AndCompoundPredicate(predicates: [
-				ValuePredicate(kind: kind, modifiers: modifiers, value: text),
+				ValuePredicate(kind: kind, modifiers: modifiers, value: text, requiresAccessibilityElement: false),
 				orCompoundPredicate
 			], modifiers: [])
-		case Kind.id, Kind.value:
+		case Kind.id:
 			let value = dictionaryRepresentation[Keys.value] as! CustomStringConvertible
-			return ValuePredicate(kind: kind, modifiers: modifiers, value: value)
+			return ValuePredicate(kind: kind, modifiers: modifiers, value: value, requiresAccessibilityElement: false)
+		case Kind.value:
+			let value = dictionaryRepresentation[Keys.value] as! CustomStringConvertible
+			return ValuePredicate(kind: kind, modifiers: modifiers, value: value, requiresAccessibilityElement: true)
 		case Kind.ancestor:
 			let predicate = try Predicate.with(dictionaryRepresentation: dictionaryRepresentation[Keys.predicate] as! [String: Any])
 			return AncestorPredicate(predicate: predicate, modifiers: modifiers)
@@ -191,6 +194,7 @@ class KindOfPredicate : Predicate {
 
 class ValuePredicate : Predicate {
 	let value : CustomStringConvertible
+	let requiresAccessibilityElement: Bool
 	
 	static let mapping : [String: (String, (Any) -> Any)] = [
 		Kind.id: ("accessibilityIdentifier", { return $0 }),
@@ -206,8 +210,9 @@ class ValuePredicate : Predicate {
 		"accessibilityValue": "value"
 	]
 	
-	init(kind: String, modifiers: Set<String>, value: CustomStringConvertible) {
+	init(kind: String, modifiers: Set<String>, value: CustomStringConvertible, requiresAccessibilityElement: Bool) {
 		self.value = value
+		self.requiresAccessibilityElement = requiresAccessibilityElement
 		
 		super.init(kind: kind, modifiers: modifiers)
 	}
@@ -301,8 +306,8 @@ class TraitPredicate : Predicate {
 	
 	override func innerPredicateForQuery() -> NSPredicate {
 		return NSPredicate.init { viewOrElse, _ -> Bool in
-			let view = viewOrElse as! UIView
-			return (view.accessibilityTraits.rawValue & self.traits.rawValue) == self.traits.rawValue
+			let view = viewOrElse as! NSObject
+			return view.isAccessibilityElement == true && (view.accessibilityTraits.rawValue & self.traits.rawValue) == self.traits.rawValue
 		}
 	}
 	
@@ -365,7 +370,7 @@ class DescendantPredicate : Predicate {
 	
 	override func innerPredicateForQuery() -> NSPredicate {
 		return NSPredicate { evaluatedObject, bindings -> Bool in
-			let view = evaluatedObject as! UIView
+			let view = evaluatedObject as! NSObject
 			
 			return UIView.dtx_findViews(inHierarchy: view, includingRoot: false, passing: self.predicate.predicateForQuery()).count > 0
 		}
@@ -395,12 +400,15 @@ class AncestorPredicate : Predicate {
 	
 	override func innerPredicateForQuery() -> NSPredicate {
 		return NSPredicate { evaluatedObject, bindings -> Bool in
-			let view = evaluatedObject as! UIView
 			let predicate = self.predicate.predicateForQuery()
 			
-			var parent : UIView? = view
+			var parent : NSObject? = evaluatedObject as! NSObject?
 			while parent != nil {
-				parent = parent!.superview
+				if let viewParent = parent as? UIView {
+					parent = viewParent.superview
+				} else {
+					parent = parent!.accessibilityContainer as? NSObject
+				}
 				if parent != nil && predicate.evaluate(with: parent) == true {
 					return true
 				}
diff --git a/detox/ios/Detox/Matchers/UIView+DetoxMatchers.h b/detox/ios/Detox/Matchers/UIView+DetoxMatchers.h
index 525f2befc..b4e7d5027 100644
--- a/detox/ios/Detox/Matchers/UIView+DetoxMatchers.h
+++ b/detox/ios/Detox/Matchers/UIView+DetoxMatchers.h
@@ -15,8 +15,8 @@ NS_ASSUME_NONNULL_BEGIN
 + (NSMutableArray<UIView*>*)dtx_findViewsInAllWindowsPassingPredicate:(NSPredicate*)predicate;
 + (NSMutableArray<UIView*>*)dtx_findViewsInKeySceneWindowsPassingPredicate:(NSPredicate*)predicate;
 + (NSMutableArray<UIView*>*)dtx_findViewsInWindows:(NSArray<UIWindow*>*)windows passingPredicate:(NSPredicate*)predicate;
-+ (NSMutableArray<UIView*>*)dtx_findViewsInHierarchy:(UIView*)hierarchy passingPredicate:(NSPredicate*)predicate;
-+ (NSMutableArray<UIView*>*)dtx_findViewsInHierarchy:(UIView*)hierarchy includingRoot:(BOOL)includingRoot passingPredicate:(NSPredicate*)predicate;
++ (NSMutableArray<UIView*>*)dtx_findViewsInHierarchy:(id)hierarchy passingPredicate:(NSPredicate*)predicate;
++ (NSMutableArray<UIView*>*)dtx_findViewsInHierarchy:(id)hierarchy includingRoot:(BOOL)includingRoot passingPredicate:(NSPredicate*)predicate;
 
 @end
 
diff --git a/detox/ios/Detox/Matchers/UIView+DetoxMatchers.m b/detox/ios/Detox/Matchers/UIView+DetoxMatchers.m
index 50fbc4586..b56ae2d64 100644
--- a/detox/ios/Detox/Matchers/UIView+DetoxMatchers.m
+++ b/detox/ios/Detox/Matchers/UIView+DetoxMatchers.m
@@ -13,6 +13,86 @@
 
 @implementation UIView (DetoxMatchers)
 
+static NSArray* DTXChildElements(id element)
+{
+	NSCParameterAssert(element != nil);
+	
+	NSMutableOrderedSet<id> *immediateChildren = [[NSMutableOrderedSet alloc] init];
+	
+	if ([element isKindOfClass:[UIView class]]) {
+		// Grab all subviews so that we continue traversing the entire hierarchy.
+		// Add the objects in reverse order to make sure that objects on top get matched first.
+		NSArray<id> *subviews = [element subviews];
+		if ([subviews count] > 0) {
+			for (UIView *subview in [subviews reverseObjectEnumerator]) {
+				[immediateChildren addObject:subview];
+			}
+		}
+	}
+	
+	// If we encounter an accessibility container, grab all the contained accessibility elements.
+	// However, we need to skip a few types of containers:
+	// 1) UITableViewCells as they do a lot of custom accessibility work underneath
+	//    and the accessibility elements they return are 'mocks' that cause access errors.
+	// 2) UITableViews as because they report all their cells, even the ones off-screen as
+	//    accessibility elements. We should not consider off-screen cells as there could be
+	//    hundreds, even thousands of them and we would be iterating over them unnecessarily.
+	//    Worse yet, if the cell isn't visible, calling accessibilityElementAtIndex will create
+	//    and initialize them each time.
+	if ([element respondsToSelector:@selector(accessibilityElementCount)] &&
+		![element isKindOfClass:[UITableView class]] &&
+		![element isKindOfClass:NSClassFromString(@"UIPickerTableView")] &&
+		![element isKindOfClass:[UITableViewCell class]])
+	{
+		NSInteger elementCount = [element accessibilityElementCount];
+		if (elementCount != NSNotFound && elementCount > 0)
+		{
+			// Temp holder created by UIKit. What we really want is the underlying element.
+			Class accessibilityMockClass = NSClassFromString(@"UIAccessibilityElementMockView");
+			Class textFieldElementClass= NSClassFromString(@"UIAccessibilityTextFieldElement");
+			for (NSInteger i = elementCount - 1; i >= 0; i--) {
+				id item = [element accessibilityElementAtIndex:i];
+				if([item isKindOfClass:accessibilityMockClass])
+				{
+					// Replace mock views with the views they encapsulate.
+					item = [item view];
+				}
+				else if([item isKindOfClass:textFieldElementClass])
+				{
+					item = [item textField];
+				}
+				
+				if (item == nil)
+				{
+					continue;
+				}
+				
+				// If item is a UIView subclass, it could be both a subview of another view and an
+				// accssibility element of a different accessibility container (which is not necessarily its
+				// superview). This could introduce elements being duplicated in the view hierarchy.
+				if ([item isKindOfClass:[UIView class]]) {
+					// Only add the item as the element's immediate children if it meets these conditions:
+					// (1) Item's superview is the element. This ensures that other accessibility containers
+					//     don't add it as their immeditate children.
+					// (2) Item does not have a superview. If item does not have a superview, you can ensure
+					//     it's being added only once as an accessibility element of a container.
+					id superview = [item superview];
+					if (superview == element || superview == nil) {
+						[immediateChildren addObject:item];
+					}
+				} else {
+					// If the item not a UIView subclass, it's mostly safe to add it as immediate child
+					// since no two accessibility containers should add the same accessible element.
+					[immediateChildren addObject:item];
+				}
+			}
+		}
+	}
+	
+	return [immediateChildren array];
+}
+
+
 + (void)_dtx_appendViewsRecursivelyFromArray:(NSArray<UIView*>*)views passingPredicate:(NSPredicate*)predicate storage:(NSMutableArray<UIView*>*)storage
 {
 	if(views.count == 0)
@@ -26,7 +106,7 @@
 			[storage addObject:obj];
 		}
 		
-		[self _dtx_appendViewsRecursivelyFromArray:obj.subviews passingPredicate:predicate storage:storage];
+		[self _dtx_appendViewsRecursivelyFromArray:DTXChildElements(obj) passingPredicate:predicate storage:storage];
 	}];
 }
 
@@ -59,7 +139,7 @@
 {
 	NSMutableArray<UIView*>* rv = [NSMutableArray new];
 	
-	[self _dtx_appendViewsRecursivelyFromArray:includingRoot ? @[hierarchy] : hierarchy.subviews passingPredicate:predicate storage:rv];
+	[self _dtx_appendViewsRecursivelyFromArray:includingRoot ? @[hierarchy] : DTXChildElements(hierarchy) passingPredicate:predicate storage:rv];
 	[self _dtx_sortViewsByCoords:rv];
 	
 	return rv;
diff --git a/detox/ios/Detox/Utilities/NSObject+DetoxUtils.h b/detox/ios/Detox/Utilities/NSObject+DetoxUtils.h
new file mode 100644
index 000000000..3a10f8667
--- /dev/null
+++ b/detox/ios/Detox/Utilities/NSObject+DetoxUtils.h
@@ -0,0 +1,61 @@
+//
+//  NSObject+DetoxUtils.h
+//  Detox
+//
+//  Created by Leo Natan on 11/12/20.
+//  Copyright © 2020 Wix. All rights reserved.
+//
+
+#import <Foundation/Foundation.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+BOOL __DTXDoulbeEqualToDouble(double a, double b);
+BOOL __DTXPointEqualToPoint(CGPoint a, CGPoint b);
+
+inline __attribute__((__always_inline__))
+static double LNLinearInterpolate(CGFloat from, CGFloat to, CGFloat p)
+{
+	return from + p * (to - from);
+}
+
+@interface NSObject (DetoxUtils)
+
+@property (nonatomic, readonly, strong) NSString* accessibilityIdentifier;
+@property (nonatomic, readonly, nullable) id accessibilityContainer;
+
+@property (nonatomic, readonly) UIView* dtx_view;
+
+- (CGPoint)dtx_convertRelativePointToViewCoordinateSpace:(CGPoint)relativePoint;
+
+@property (nonatomic, readonly) CGRect dtx_bounds;
+@property (nonatomic, readonly) CGRect dtx_contentBounds;
+@property (nonatomic, readonly) CGRect dtx_visibleBounds;
+
+@property (nonatomic, readonly) BOOL dtx_isVisible;
+- (BOOL)dtx_isVisibleAtRect:(CGRect)rect;
+- (BOOL)dtx_isVisibleAtRect:(CGRect)rect error:(NSError* __strong * __nullable)error;
+- (void)dtx_assertVisible;
+- (void)dtx_assertVisibleVisibleAtRect:(CGRect)rect;
+
+@property (nonatomic, readonly) BOOL dtx_isHittable;
+- (BOOL)dtx_isHittableAtPoint:(CGPoint)point;
+- (BOOL)dtx_isHittableAtPoint:(CGPoint)point error:(NSError* __strong * __nullable)error;
+- (void)dtx_assertHittable;
+- (void)dtx_assertHittableAtPoint:(CGPoint)point;
+
+@property (nonatomic, readonly) BOOL dtx_isEnabled;
+- (void)dtx_assertEnabled;
+
+@property (nonatomic, readonly, copy) NSString* dtx_shortDescription;
+@property (nonatomic, readonly) CGRect dtx_accessibilityFrame;
+@property (nonatomic, readonly) CGRect dtx_safeAreaBounds;
+@property (nonatomic, readonly) CGPoint dtx_accessibilityActivationPoint;
+@property (nonatomic, readonly) CGPoint dtx_accessibilityActivationPointInViewCoordinateSpace;
+
+@property (nonatomic, readonly, copy) NSDictionary<NSString*, id>* dtx_attributes;
+@property (nonatomic, readonly, copy) NSDictionary<NSString*, id>* dtx_elementDebugAttributes;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/detox/ios/Detox/Utilities/NSObject+DetoxUtils.m b/detox/ios/Detox/Utilities/NSObject+DetoxUtils.m
new file mode 100644
index 000000000..5e7c77a6e
--- /dev/null
+++ b/detox/ios/Detox/Utilities/NSObject+DetoxUtils.m
@@ -0,0 +1,342 @@
+//
+//  NSObject+DetoxUtils.m
+//  Detox
+//
+//  Created by Leo Natan on 11/12/20.
+//  Copyright © 2020 Wix. All rights reserved.
+//
+
+@import UIKit;
+#import "NSObject+DetoxUtils.h"
+#import "UISlider+DetoxUtils.h"
+#import "DTXAppleInternals.h"
+
+DTX_ALWAYS_INLINE
+static id DTXJSONSafeNSNumberOrString(double d)
+{
+	return isnan(d) ? @"NaN" : @(d);
+}
+
+DTX_ALWAYS_INLINE
+static NSDictionary* DTXInsetsToDictionary(UIEdgeInsets insets)
+{
+	return @{@"top": DTXJSONSafeNSNumberOrString(insets.top), @"bottom": DTXJSONSafeNSNumberOrString(insets.bottom), @"left": DTXJSONSafeNSNumberOrString(insets.left), @"right": DTXJSONSafeNSNumberOrString(insets.right)};
+}
+
+DTX_ALWAYS_INLINE
+static NSDictionary* DTXRectToDictionary(CGRect rect)
+{
+	return @{@"x": DTXJSONSafeNSNumberOrString(rect.origin.x), @"y": DTXJSONSafeNSNumberOrString(rect.origin.y), @"width": DTXJSONSafeNSNumberOrString(rect.size.width), @"height": DTXJSONSafeNSNumberOrString(rect.size.height)};
+}
+
+DTX_ALWAYS_INLINE
+static NSDictionary* DTXPointToDictionary(CGPoint point)
+{
+	return @{@"x": DTXJSONSafeNSNumberOrString(point.x), @"y": DTXJSONSafeNSNumberOrString(point.y)};
+}
+
+DTX_ALWAYS_INLINE
+static NSString* DTXPointToString(CGPoint point)
+{
+	return [[NSString alloc] initWithData:[NSJSONSerialization dataWithJSONObject:DTXPointToDictionary(point) options:0 error:NULL] encoding:NSUTF8StringEncoding];
+}
+
+@interface NSObject ()
+
+@property (readonly) id<UICoordinateSpace> coordinateSpace;
+
+@end
+
+BOOL __DTXDoulbeEqualToDouble(double a, double b)
+{
+	double difference = fabs(a * .00001);
+	return fabs(a - b) <= difference;
+}
+
+BOOL __DTXPointEqualToPoint(CGPoint a, CGPoint b)
+{
+	return __DTXDoulbeEqualToDouble(floor(a.x), floor(b.x)) && __DTXDoulbeEqualToDouble(floor(a.y), floor(b.y));
+}
+
+@implementation NSObject (DetoxUtils)
+
+@dynamic accessibilityIdentifier;
+@dynamic accessibilityContainer;
+
+- (CGPoint)dtx_convertRelativePointToViewCoordinateSpace:(CGPoint)relativePoint
+{
+	if([self isKindOfClass:UIView.class])
+	{
+		return relativePoint;
+	}
+	
+	CGPoint screenPoint = CGPointMake(self.accessibilityFrame.origin.x + relativePoint.x, self.accessibilityFrame.origin.y + relativePoint.y);
+	return [self.dtx_view.window.screen.coordinateSpace convertPoint:screenPoint toCoordinateSpace:self.dtx_view.coordinateSpace];
+}
+
+- (UIView*)dtx_view
+{
+	if([self isKindOfClass:UIView.class])
+	{
+		return (id)self;
+	}
+	
+	return self.dtx_viewContainer;
+}
+
+- (UIView*)dtx_viewContainer
+{
+	if([self isKindOfClass:UIView.class])
+	{
+		return self.dtx_container;
+	}
+	else if([self respondsToSelector:@selector(accessibilityContainer)])
+	{
+		id container = self.dtx_container;
+		
+		if([container isKindOfClass:UIView.class] == NO)
+		{
+			return [container dtx_viewContainer];
+		}
+		
+		return container;
+	}
+	
+	return nil;
+}
+
+- (id)dtx_container
+{
+	if ([self isKindOfClass:UIView.class])
+	{
+		return [(UIView *)self superview];
+	}
+	else if ([self respondsToSelector:@selector(accessibilityContainer)])
+	{
+		return [(UIAccessibilityElement*)self accessibilityContainer];
+	}
+
+	return nil;
+}
+
+- (CGRect)dtx_bounds
+{
+	if([self isKindOfClass:UIView.class])
+	{
+		return [(UIView*)self bounds];
+	}
+	
+	UIView* view = self.dtx_view;
+	return [view.window.screen.coordinateSpace convertRect:self.accessibilityFrame toCoordinateSpace:view.coordinateSpace];
+}
+
+- (CGRect)dtx_contentBounds
+{
+	return self.dtx_bounds;
+}
+
+- (CGRect)dtx_visibleBounds
+{
+	return self.dtx_bounds;
+}
+
+- (BOOL)dtx_isVisible
+{
+	return [self.dtx_view dtx_isVisibleAtRect:self.dtx_bounds];
+}
+
+- (BOOL)dtx_isVisibleAtRect:(CGRect)rect
+{
+	return [self.dtx_view dtx_isVisibleAtRect:self.dtx_bounds];
+}
+
+- (BOOL)dtx_isVisibleAtRect:(CGRect)rect error:(NSError *__strong  _Nullable *)error
+{
+	return [self.dtx_view dtx_isVisibleAtRect:self.dtx_bounds error:error];
+}
+
+- (void)dtx_assertVisible
+{
+	[self.dtx_view dtx_assertVisibleVisibleAtRect:self.dtx_bounds];
+}
+
+- (void)dtx_assertVisibleVisibleAtRect:(CGRect)rect
+{
+	[self.dtx_view dtx_assertVisibleVisibleAtRect:self.dtx_bounds];
+}
+
+- (BOOL)dtx_isHittable
+{
+	return YES;
+}
+
+- (BOOL)dtx_isHittableAtPoint:(CGPoint)point
+{
+	return YES;
+}
+
+- (BOOL)dtx_isHittableAtPoint:(CGPoint)point error:(NSError* __strong * __nullable)error
+{
+	return YES;
+}
+
+- (void)dtx_assertHittable
+{
+	
+}
+
+- (void)dtx_assertHittableAtPoint:(CGPoint)point
+{
+	
+}
+
+- (BOOL)dtx_isEnabled
+{
+	return self.dtx_view.dtx_isEnabled;
+}
+
+- (void)dtx_assertEnabled
+{
+	return [self.dtx_view dtx_assertEnabled];
+}
+
+- (NSString *)dtx_shortDescription
+{
+	return self.description;
+}
+
+- (CGRect)dtx_accessibilityFrame
+{
+	return self.accessibilityFrame;
+}
+
+- (CGRect)dtx_safeAreaBounds
+{
+	return self.dtx_bounds;
+}
+
+- (CGPoint)dtx_accessibilityActivationPoint
+{
+	return self.accessibilityActivationPoint;
+}
+
+- (CGPoint)dtx_accessibilityActivationPointInViewCoordinateSpace
+{
+	UIView* view = self.dtx_view;
+	return [view.window.screen.coordinateSpace convertPoint:self.accessibilityActivationPoint toCoordinateSpace:view.coordinateSpace];
+}
+
+- (NSDictionary<NSString *,id> *)dtx_attributes
+{
+	NSMutableDictionary* rv = [NSMutableDictionary new];
+	
+	NSDictionary* results = [self dictionaryWithValuesForKeys:@[@"text", @"accessibilityLabel", @"accessibilityIdentifier", @"accessibilityValue", @"placeholder"]];
+	[results enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
+		if([obj isKindOfClass:NSNull.class])
+		{
+			return;
+		}
+		
+		if([key isEqualToString:@"accessibilityLabel"])
+		{
+			rv[@"label"] = obj;
+		}
+		else if([key isEqualToString:@"accessibilityValue"])
+		{
+			rv[@"value"] = obj;
+		}
+		else if([key isEqualToString:@"accessibilityIdentifier"])
+		{
+			rv[@"identifier"] = obj;
+		}
+		else
+		{
+			rv[key] = obj;
+		}
+	}];
+	
+	rv[@"enabled"] = @(self.dtx_isEnabled);
+	
+	rv[@"frame"] = DTXRectToDictionary(self.dtx_accessibilityFrame);
+	rv[@"elementSafeBounds"] = DTXRectToDictionary(self.dtx_safeAreaBounds);
+	
+	if([self isKindOfClass:UIView.class])
+	{
+		UIView* view = (id)self;
+		rv[@"elementFrame"] = DTXRectToDictionary(view.frame);
+		rv[@"elementBounds"] = DTXRectToDictionary(view.bounds);
+		rv[@"safeAreaInsets"] = DTXInsetsToDictionary(view.safeAreaInsets);
+		rv[@"layer"] = view.layer.description;
+	}
+	else
+	{
+		rv[@"isAccessibilityElement"] = @(self.isAccessibilityElement);
+	}
+	
+	CGPoint accessibilityActivationPoint = self.dtx_accessibilityActivationPoint;
+	CGPoint accessibilityActivationPointInViewCoordinateSpace = self.dtx_accessibilityActivationPointInViewCoordinateSpace;
+	rv[@"activationPoint"] = DTXPointToDictionary(accessibilityActivationPointInViewCoordinateSpace);
+	CGRect accessibilityFrame = self.dtx_accessibilityFrame;
+	rv[@"normalizedActivationPoint"] = DTXPointToDictionary(CGPointMake(CGRectGetWidth(accessibilityFrame) == 0 ? 0 : (accessibilityActivationPoint.x - CGRectGetMinX(accessibilityFrame)) / CGRectGetWidth(accessibilityFrame), CGRectGetHeight(accessibilityFrame) == 0 ? 0 : (accessibilityActivationPoint.y - CGRectGetMinY(accessibilityFrame)) / CGRectGetHeight(accessibilityFrame)));
+	
+	rv[@"hittable"] = @(self.dtx_isHittable);
+	rv[@"visible"] = @(self.dtx_isVisible);
+	
+	if([self isKindOfClass:UIScrollView.class])
+	{
+		rv[@"contentInset"] = DTXInsetsToDictionary([(UIScrollView*)self contentInset]);
+		rv[@"adjustedContentInset"] = DTXInsetsToDictionary([(UIScrollView*)self adjustedContentInset]);
+		rv[@"contentOffset"] = DTXPointToDictionary([(UIScrollView*)self contentOffset]);
+	}
+	
+	if([self isKindOfClass:UISlider.class])
+	{
+		rv[@"normalizedSliderPosition"] = @([(UISlider*)self dtx_normalizedSliderPosition]);
+	}
+	
+	if([self isKindOfClass:UIDatePicker.class])
+	{
+		UIDatePicker* dp = (id)self;
+		rv[@"date"] = [NSISO8601DateFormatter stringFromDate:dp.date timeZone:dp.timeZone ?: NSTimeZone.systemTimeZone formatOptions:NSISO8601DateFormatWithInternetDateTime | NSISO8601DateFormatWithDashSeparatorInDate | NSISO8601DateFormatWithColonSeparatorInTime | NSISO8601DateFormatWithColonSeparatorInTimeZone];
+		NSDateComponents* dc = [dp.calendar componentsInTimeZone:dp.timeZone ?: NSTimeZone.systemTimeZone fromDate:dp.date];
+		
+		NSMutableDictionary* dateComponents = [NSMutableDictionary new];
+		dateComponents[@"era"] = @(dc.era);
+		dateComponents[@"year"] = @(dc.year);
+		dateComponents[@"month"] = @(dc.month);
+		dateComponents[@"day"] = @(dc.day);
+		dateComponents[@"hour"] = @(dc.hour);
+		dateComponents[@"minute"] = @(dc.minute);
+		dateComponents[@"second"] = @(dc.second);
+		dateComponents[@"weekday"] = @(dc.weekday);
+		dateComponents[@"weekdayOrdinal"] = @(dc.weekdayOrdinal);
+		dateComponents[@"quarter"] = @(dc.quarter);
+		dateComponents[@"weekOfMonth"] = @(dc.weekOfMonth);
+		dateComponents[@"weekOfYear"] = @(dc.weekOfYear);
+		dateComponents[@"leapMonth"] = @(dc.leapMonth);
+		
+		rv[@"dateComponents"] = dateComponents;
+	}
+	
+	return rv;
+}
+
+- (NSDictionary<NSString *,id> *)dtx_elementDebugAttributes
+{
+	NSMutableDictionary* rv = [NSMutableDictionary new];
+	
+	UIWindow* window = self.dtx_view.window;
+	
+	if(window != nil)
+	{
+		rv[@"viewHierarchy"] = window.recursiveDescription;
+	}
+	
+	rv[@"elementAttributes"] = [self dtx_attributes];
+	rv[@"viewDescription"] = self.description;
+	
+	return rv;
+}
+
+
+@end
diff --git a/detox/ios/Detox/Utilities/UIView+DontCrash.h b/detox/ios/Detox/Utilities/NSObject+DontCrash.h
similarity index 70%
rename from detox/ios/Detox/Utilities/UIView+DontCrash.h
rename to detox/ios/Detox/Utilities/NSObject+DontCrash.h
index ee9af54ca..de266e5b8 100644
--- a/detox/ios/Detox/Utilities/UIView+DontCrash.h
+++ b/detox/ios/Detox/Utilities/NSObject+DontCrash.h
@@ -1,5 +1,5 @@
 //
-//  UIView+DontCrash.h
+//  NSObject+DontCrash.h
 //  ExampleApp
 //
 //  Created by Leo Natan (Wix) on 4/16/20.
@@ -9,7 +9,7 @@
 
 NS_ASSUME_NONNULL_BEGIN
 
-@interface UIView (DontCrash)
+@interface NSObject (DontCrash)
 
 @end
 
diff --git a/detox/ios/Detox/Utilities/UIView+DontCrash.m b/detox/ios/Detox/Utilities/NSObject+DontCrash.m
similarity index 76%
rename from detox/ios/Detox/Utilities/UIView+DontCrash.m
rename to detox/ios/Detox/Utilities/NSObject+DontCrash.m
index 13efab9ba..bf39b58d9 100644
--- a/detox/ios/Detox/Utilities/UIView+DontCrash.m
+++ b/detox/ios/Detox/Utilities/NSObject+DontCrash.m
@@ -1,13 +1,13 @@
 //
-//  UIView+DontCrash.m
+//  NSObject+DontCrash.m
 //  ExampleApp
 //
 //  Created by Leo Natan (Wix) on 4/16/20.
 //
 
-#import "UIView+DontCrash.h"
+#import "NSObject+DontCrash.h"
 
-@implementation UIView (DontCrash)
+@implementation NSObject (DontCrash)
 
 - (id)text
 {
diff --git a/detox/ios/Detox/Utilities/UIView+DetoxUtils.h b/detox/ios/Detox/Utilities/UIView+DetoxUtils.h
index da52eacd3..5394bdc9d 100644
--- a/detox/ios/Detox/Utilities/UIView+DetoxUtils.h
+++ b/detox/ios/Detox/Utilities/UIView+DetoxUtils.h
@@ -7,47 +7,12 @@
 //
 
 #import <UIKit/UIKit.h>
+#import <NSObject+DetoxUtils.h>
 
 NS_ASSUME_NONNULL_BEGIN
 
-BOOL __DTXDoulbeEqualToDouble(double a, double b);
-BOOL __DTXPointEqualToPoint(CGPoint a, CGPoint b);
-
-inline __attribute__((__always_inline__))
-static double LNLinearInterpolate(CGFloat from, CGFloat to, CGFloat p)
-{
-	return from + p * (to - from);
-}
-
 @interface UIView (DetoxUtils)
 
-@property (nonatomic, readonly) CGRect dtx_contentBounds;
-@property (nonatomic, readonly) CGRect dtx_visibleBounds;
-
-@property (nonatomic, readonly) BOOL dtx_isVisible;
-- (BOOL)dtx_isVisibleAtPoint:(CGPoint)point;
-- (BOOL)dtx_isVisibleAtPoint:(CGPoint)point error:(NSError* __strong * __nullable)error;
-- (void)dtx_assertVisible;
-- (void)dtx_assertVisibleAtPoint:(CGPoint)point;
-
-@property (nonatomic, readonly) BOOL dtx_isHittable;
-- (BOOL)dtx_isHittableAtPoint:(CGPoint)point;
-- (BOOL)dtx_isHittableAtPoint:(CGPoint)point error:(NSError* __strong * __nullable)error;
-- (void)dtx_assertHittable;
-- (void)dtx_assertHittableAtPoint:(CGPoint)point;
-
-@property (nonatomic, readonly) BOOL dtx_isEnabled;
-- (void)dtx_assertEnabled;
-
-@property (nonatomic, readonly, copy) NSString* dtx_shortDescription;
-@property (nonatomic, readonly) CGRect dtx_accessibilityFrame;
-@property (nonatomic, readonly) CGRect dtx_safeAreaBounds;
-@property (nonatomic, readonly) CGPoint dtx_accessibilityActivationPoint;
-@property (nonatomic, readonly) CGPoint dtx_accessibilityActivationPointInViewCoordinateSpace;
-
-@property (nonatomic, readonly, copy) NSDictionary<NSString*, id>* dtx_attributes;
-@property (nonatomic, readonly, copy) NSDictionary<NSString*, id>* dtx_viewDebugAttributes;
-
 @property (nonatomic, readonly, weak) UIViewController* dtx_containingViewController;
 
 @end
diff --git a/detox/ios/Detox/Utilities/UIView+DetoxUtils.m b/detox/ios/Detox/Utilities/UIView+DetoxUtils.m
index ce711ae7f..dccf33b4a 100644
--- a/detox/ios/Detox/Utilities/UIView+DetoxUtils.m
+++ b/detox/ios/Detox/Utilities/UIView+DetoxUtils.m
@@ -9,7 +9,6 @@
 #import "UIView+DetoxUtils.h"
 #import "DTXAppleInternals.h"
 #import "UIWindow+DetoxUtils.h"
-#import "UISlider+DetoxUtils.h"
 #import "UIImage+DetoxUtils.h"
 #import "UIView+Drawing.h"
 #import "DetoxPolicy.h"
@@ -23,55 +22,14 @@
 //#endif
 #define APPLY_PREFIX(...) [NSString stringWithFormat:@"%@ %@", prefix, __VA_ARGS__]
 
-DTX_ALWAYS_INLINE
-static id DTXJSONSafeNSNumberOrString(double d)
-{
-	return isnan(d) ? @"NaN" : @(d);
-}
-
-DTX_ALWAYS_INLINE
-static NSDictionary* DTXInsetsToDictionary(UIEdgeInsets insets)
-{
-	return @{@"top": DTXJSONSafeNSNumberOrString(insets.top), @"bottom": DTXJSONSafeNSNumberOrString(insets.bottom), @"left": DTXJSONSafeNSNumberOrString(insets.left), @"right": DTXJSONSafeNSNumberOrString(insets.right)};
-}
-
-DTX_ALWAYS_INLINE
-static NSDictionary* DTXRectToDictionary(CGRect rect)
-{
-	return @{@"x": DTXJSONSafeNSNumberOrString(rect.origin.x), @"y": DTXJSONSafeNSNumberOrString(rect.origin.y), @"width": DTXJSONSafeNSNumberOrString(rect.size.width), @"height": DTXJSONSafeNSNumberOrString(rect.size.height)};
-}
-
-DTX_ALWAYS_INLINE
-static NSDictionary* DTXPointToDictionary(CGPoint point)
-{
-	return @{@"x": DTXJSONSafeNSNumberOrString(point.x), @"y": DTXJSONSafeNSNumberOrString(point.y)};
-}
-
-DTX_ALWAYS_INLINE
-static NSString* DTXPointToString(CGPoint point)
-{
-	return [[NSString alloc] initWithData:[NSJSONSerialization dataWithJSONObject:DTXPointToDictionary(point) options:0 error:NULL] encoding:NSUTF8StringEncoding];
-}
-
 @import ObjectiveC;
 
-BOOL __DTXDoulbeEqualToDouble(double a, double b)
-{
-	double difference = fabs(a * .00001);
-	return fabs(a - b) <= difference;
-}
-
-BOOL __DTXPointEqualToPoint(CGPoint a, CGPoint b)
-{
-	return __DTXDoulbeEqualToDouble(floor(a.x), floor(b.x)) && __DTXDoulbeEqualToDouble(floor(a.y), floor(b.y));
-}
-
 DTX_DIRECT_MEMBERS
 @implementation UIView (DetoxUtils)
 
 - (void)dtx_assertVisible
 {
-	[self _dtx_assertVisibleAtPoint:self.dtx_accessibilityActivationPointInViewCoordinateSpace isAtActivationPoint:YES];
+	[self dtx_assertVisibleAtRect:self.bounds];
 }
 
 - (void)dtx_assertHittable
@@ -79,9 +37,12 @@ DTX_DIRECT_MEMBERS
 	[self _dtx_assertHittableAtPoint:self.dtx_accessibilityActivationPointInViewCoordinateSpace isAtActivationPoint:YES];
 }
 
-- (void)dtx_assertVisibleAtPoint:(CGPoint)point
+- (void)dtx_assertVisibleAtRect:(CGRect)rect
 {
-	[self _dtx_assertVisibleAtPoint:point isAtActivationPoint:NO];
+	NSError* error;
+	BOOL assert = [self dtx_isVisibleAtRect:rect error:&error];
+	
+	DTXViewAssert(assert == YES, self.dtx_elementDebugAttributes, @"%@", error.localizedDescription);
 }
 
 - (void)dtx_assertHittableAtPoint:(CGPoint)point
@@ -89,20 +50,12 @@ DTX_DIRECT_MEMBERS
 	[self _dtx_assertHittableAtPoint:point isAtActivationPoint:NO];
 }
 
-- (void)_dtx_assertVisibleAtPoint:(CGPoint)point isAtActivationPoint:(BOOL)isAtActivationPoint
-{
-	NSError* error;
-	BOOL assert = [self dtx_isVisibleAtPoint:point error:&error];
-	
-	DTXViewAssert(assert == YES, self.dtx_viewDebugAttributes, @"%@", error.localizedDescription);
-}
-
 - (void)_dtx_assertHittableAtPoint:(CGPoint)point isAtActivationPoint:(BOOL)isAtActivationPoint
 {
 	NSError* error;
 	BOOL assert = [self dtx_isHittableAtPoint:point error:&error];
 	
-	DTXViewAssert(assert == YES, self.dtx_viewDebugAttributes, @"%@", error.localizedDescription);
+	DTXViewAssert(assert == YES, self.dtx_elementDebugAttributes, @"%@", error.localizedDescription);
 }
 
 - (NSString *)dtx_shortDescription
@@ -184,12 +137,12 @@ DTX_DIRECT_MEMBERS
 
 - (BOOL)dtx_isVisible
 {
-	return [self dtx_isVisibleAtPoint:self.dtx_accessibilityActivationPointInViewCoordinateSpace error:NULL];
+	return [self dtx_isVisibleAtRect:self.bounds error:NULL];
 }
 
-- (BOOL)dtx_isVisibleAtPoint:(CGPoint)point
+- (BOOL)dtx_isVisibleAtRect:(CGRect)rect
 {
-	return [self dtx_isVisibleAtPoint:point error:NULL];
+	return [self dtx_isVisibleAtRect:rect error:NULL];
 }
 
 - (UIImage*)_dtx_imageForVisibilityTestingInWindow:(UIWindow*)windowToUse testedView:(UIView*)testedView inRect:(CGRect)testedRect drawTestedRect:(BOOL)drawTestedRect
@@ -361,9 +314,9 @@ DTX_DIRECT_MEMBERS
 	return YES;
 }
 
-- (BOOL)dtx_isVisibleAtPoint:(CGPoint)point error:(NSError* __strong *)error
+- (BOOL)dtx_isVisibleAtRect:(CGRect)rect error:(NSError* __strong *)error
 {
-	return [self _dtx_testVisibilityInRect:self.bounds error:error];
+	return [self _dtx_testVisibilityInRect:rect error:error];
 }
 
 - (BOOL)dtx_isHittable
@@ -398,105 +351,7 @@ DTX_DIRECT_MEMBERS
 
 - (void)dtx_assertEnabled
 {
-	DTXViewAssert(self.dtx_isEnabled == YES, self.dtx_viewDebugAttributes, @"View is not enabled.");
-}
-
-- (NSDictionary<NSString *,id> *)dtx_attributes
-{
-	NSMutableDictionary* rv = [NSMutableDictionary new];
-	
-	NSDictionary* results = [self dictionaryWithValuesForKeys:@[@"text", @"accessibilityLabel", @"accessibilityIdentifier", @"accessibilityValue", @"placeholder"]];
-	[results enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
-		if([obj isKindOfClass:NSNull.class])
-		{
-			return;
-		}
-		
-		if([key isEqualToString:@"accessibilityLabel"])
-		{
-			rv[@"label"] = obj;
-		}
-		else if([key isEqualToString:@"accessibilityValue"])
-		{
-			rv[@"value"] = obj;
-		}
-		else if([key isEqualToString:@"accessibilityIdentifier"])
-		{
-			rv[@"identifier"] = obj;
-		}
-		else
-		{
-			rv[key] = obj;
-		}
-	}];
-	
-	rv[@"enabled"] = @(self.dtx_isEnabled);
-	
-	rv[@"frame"] = DTXRectToDictionary(self.dtx_accessibilityFrame);
-	rv[@"elementFrame"] = DTXRectToDictionary(self.frame);
-	rv[@"elementBounds"] = DTXRectToDictionary(self.bounds);
-	rv[@"safeAreaInsets"] = DTXInsetsToDictionary(self.safeAreaInsets);
-	rv[@"elementSafeBounds"] = DTXRectToDictionary(self.dtx_safeAreaBounds);
-	
-	CGPoint accessibilityActivationPointInViewCoordinateSpace = self.dtx_accessibilityActivationPointInViewCoordinateSpace;
-	rv[@"activationPoint"] = DTXPointToDictionary(accessibilityActivationPointInViewCoordinateSpace);
-	rv[@"normalizedActivationPoint"] = DTXPointToDictionary(CGPointMake(CGRectGetWidth(self.bounds) == 0 ? 0 : accessibilityActivationPointInViewCoordinateSpace.x / CGRectGetWidth(self.bounds), CGRectGetHeight(self.bounds) == 0 ? 0 : accessibilityActivationPointInViewCoordinateSpace.y / CGRectGetHeight(self.bounds)));
-	
-	rv[@"hittable"] = @(self.dtx_isHittable);
-	rv[@"visible"] = @(self.dtx_isVisible);
-	
-	if([self isKindOfClass:UIScrollView.class])
-	{
-		rv[@"contentInset"] = DTXInsetsToDictionary([(UIScrollView*)self contentInset]);
-		rv[@"adjustedContentInset"] = DTXInsetsToDictionary([(UIScrollView*)self adjustedContentInset]);
-		rv[@"contentOffset"] = DTXPointToDictionary([(UIScrollView*)self contentOffset]);
-	}
-	
-	if([self isKindOfClass:UISlider.class])
-	{
-		rv[@"normalizedSliderPosition"] = @([(UISlider*)self dtx_normalizedSliderPosition]);
-	}
-	
-	if([self isKindOfClass:UIDatePicker.class])
-	{
-		UIDatePicker* dp = (id)self;
-		rv[@"date"] = [NSISO8601DateFormatter stringFromDate:dp.date timeZone:dp.timeZone ?: NSTimeZone.systemTimeZone formatOptions:NSISO8601DateFormatWithInternetDateTime | NSISO8601DateFormatWithDashSeparatorInDate | NSISO8601DateFormatWithColonSeparatorInTime | NSISO8601DateFormatWithColonSeparatorInTimeZone];
-		NSDateComponents* dc = [dp.calendar componentsInTimeZone:dp.timeZone ?: NSTimeZone.systemTimeZone fromDate:dp.date];
-		
-		NSMutableDictionary* dateComponents = [NSMutableDictionary new];
-		dateComponents[@"era"] = @(dc.era);
-		dateComponents[@"year"] = @(dc.year);
-		dateComponents[@"month"] = @(dc.month);
-		dateComponents[@"day"] = @(dc.day);
-		dateComponents[@"hour"] = @(dc.hour);
-		dateComponents[@"minute"] = @(dc.minute);
-		dateComponents[@"second"] = @(dc.second);
-		dateComponents[@"weekday"] = @(dc.weekday);
-		dateComponents[@"weekdayOrdinal"] = @(dc.weekdayOrdinal);
-		dateComponents[@"quarter"] = @(dc.quarter);
-		dateComponents[@"weekOfMonth"] = @(dc.weekOfMonth);
-		dateComponents[@"weekOfYear"] = @(dc.weekOfYear);
-		dateComponents[@"leapMonth"] = @(dc.leapMonth);
-		
-		rv[@"dateComponents"] = dateComponents;
-	}
-	
-	return rv;
-}
-
-- (NSDictionary<NSString *,id> *)dtx_viewDebugAttributes
-{
-	NSMutableDictionary* rv = [NSMutableDictionary new];
-	
-	if(self.window != nil)
-	{
-		rv[@"viewHierarchy"] = self.window.recursiveDescription;
-	}
-	
-	rv[@"elementAttributes"] = [self dtx_attributes];
-	rv[@"viewDescription"] = self.description;
-	
-	return rv;
+	DTXViewAssert(self.dtx_isEnabled == YES, self.dtx_elementDebugAttributes, @"View is not enabled.");
 }
 
 - (UIViewController *)dtx_containingViewController
diff --git a/detox/package.json b/detox/package.json
index 36ee927f9..c0eed62af 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -1,7 +1,7 @@
 {
   "name": "detox",
   "description": "E2E tests and automation for mobile",
-  "version": "17.11.4",
+  "version": "17.12.0",
   "bin": {
     "detox": "local-cli/cli.js"
   },
@@ -96,11 +96,19 @@
       "src/artifacts/video",
       "src/devices/drivers/ios/tools/AppleSimUtils.js",
       "src/server/DetoxServer.js",
-      ".*Driver.js",
+      "DeviceDriverBase.js",
+      "AndroidDriver.js",
+      "AttachedAndroidDriver.js",
+      "EmulatorDriver.js",
+      "IosDriver.js",
+      "SimulatorDriver.js",
+      "AndroidDeviceAllocator.js",
+      "AttachedDeviceAllocator.js",
       "MissingDetox.js",
       "EmulatorTelnet.js",
       "EmulatorExec.js",
       "EmulatorLauncher.js",
+      "EmulatorDeviceAllocator.js",
       "DeviceDriverBase.js",
       "GREYConfiguration.js",
       "src/utils/environment.js",
diff --git a/detox/src/devices/DeviceRegistry.js b/detox/src/devices/DeviceRegistry.js
index 4656f9bdd..8b34b904a 100644
--- a/detox/src/devices/DeviceRegistry.js
+++ b/detox/src/devices/DeviceRegistry.js
@@ -44,10 +44,14 @@ class DeviceRegistry {
     });
   }
 
-  isDeviceBusy(deviceId) {
+  includes(deviceId) {
     return this._lockfile.read().includes(deviceId);
   }
 
+  getRegisteredDevices() {
+    return this._lockfile.read();
+  }
+
   /***
    * @private
    */
@@ -60,11 +64,9 @@ class DeviceRegistry {
    */
   _toggleDeviceStatus(deviceId, busy) {
     const state = this._lockfile.read();
-
     const newState = busy
       ? _.concat(state, deviceId)
       : _.without(state, deviceId);
-
     this._lockfile.write(newState);
   }
 
diff --git a/detox/src/devices/drivers/android/AndroidDeviceAllocator.js b/detox/src/devices/drivers/android/AndroidDeviceAllocator.js
new file mode 100644
index 000000000..915d29a4a
--- /dev/null
+++ b/detox/src/devices/drivers/android/AndroidDeviceAllocator.js
@@ -0,0 +1,21 @@
+const ALLOCATE_DEVICE_LOG_EVT = 'ALLOCATE_DEVICE';
+
+class AndroidDeviceAllocator {
+  constructor(deviceRegistry, logger) {
+    this.deviceRegistry = deviceRegistry;
+    this.logger = logger;
+  }
+
+  async allocateDevice(deviceQuery) {
+    this.logger.debug({ event: ALLOCATE_DEVICE_LOG_EVT }, `Trying to allocate a device based on "${deviceQuery}"`);
+    const deviceId = await this._doAllocateDevice(deviceQuery);
+    this.logger.debug({ event: ALLOCATE_DEVICE_LOG_EVT }, `Settled on ${deviceId}`);
+    return deviceId;
+  }
+
+  async _doAllocateDevice(deviceQuery) {
+    throw new Error('Not implemented!');
+  }
+}
+
+module.exports = AndroidDeviceAllocator;
diff --git a/detox/src/devices/drivers/android/AndroidDriver.js b/detox/src/devices/drivers/android/AndroidDriver.js
index bf9ea0a09..e58b6454d 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.js
@@ -1,3 +1,4 @@
+const _ = require('lodash');
 const path = require('path');
 const fs = require('fs');
 const URL = require('url').URL;
@@ -24,9 +25,6 @@ const temporaryPath = require('../../../artifacts/utils/temporaryPath');
 const sleep = require('../../../utils/sleep');
 const retry = require('../../../utils/retry');
 const getAbsoluteBinaryPath = require('../../../utils/getAbsoluteBinaryPath');
-const DeviceRegistry = require('../../DeviceRegistry');
-
-const ALLOCATE_DEVICE_LOG_EVT = 'ALLOCATE_DEVICE';
 
 class AndroidDriver extends DeviceDriverBase {
   constructor(config) {
@@ -43,8 +41,6 @@ class AndroidDriver extends DeviceDriverBase {
     this.devicePathBuilder = new AndroidDevicePathBuilder();
 
     this.instrumentation = new MonitoredInstrumentation(this.adb, logger);
-
-    this.deviceRegistry = DeviceRegistry.forAndroid();
   }
 
   declareArtifactPlugins() {
@@ -64,52 +60,42 @@ class AndroidDriver extends DeviceDriverBase {
     return await this.aapt.getPackageName(binaryPath);
   }
 
-  async allocateDevice(deviceQuery) {
-    log.debug({ event: ALLOCATE_DEVICE_LOG_EVT }, `Trying to allocate a device based on "${deviceQuery}"`);
-    const adbName = await this.deviceRegistry.allocateDevice(() => this.doAllocateDevice(deviceQuery));
-    log.debug({ event: ALLOCATE_DEVICE_LOG_EVT }, `Settled on ${adbName}`);
-    return adbName;
-  }
-
-  /**
-   * @protected
-   * @return {Promise<string>} adbName of a free matching device
-   */
-  async doAllocateDevice(deviceQuery) {
-    throw Error('Not implemented!');
-  }
-
   async installApp(deviceId, _binaryPath, _testBinaryPath) {
+    const adbName = this._getAdbName(deviceId);
     const {
       binaryPath,
       testBinaryPath,
     } = this._getInstallPaths(_binaryPath, _testBinaryPath);
-    await this.adb.install(deviceId, binaryPath);
-    await this.adb.install(deviceId, testBinaryPath);
+    await this.adb.install(adbName, binaryPath);
+    await this.adb.install(adbName, testBinaryPath);
   }
 
   async uninstallApp(deviceId, bundleId) {
-    await this.emitter.emit('beforeUninstallApp', { deviceId, bundleId });
-    await this.appUninstallHelper.uninstall(deviceId, bundleId);
+    const adbName = this._getAdbName(deviceId);
+    await this.emitter.emit('beforeUninstallApp', { deviceId: adbName, bundleId });
+    await this.appUninstallHelper.uninstall(adbName, bundleId);
   }
 
   async installUtilBinaries(deviceId, paths) {
+    const adbName = this._getAdbName(deviceId);
     for (const path of paths) {
       const packageId = await this.getBundleIdFromBinary(path);
-      if (!await this.adb.isPackageInstalled(deviceId, packageId)) {
-        await this.appInstallHelper.install(deviceId, path);
+      if (!await this.adb.isPackageInstalled(adbName, packageId)) {
+        await this.appInstallHelper.install(adbName, path);
       }
     }
   }
 
   async launchApp(deviceId, bundleId, launchArgs, languageAndLocale) {
-    await this.emitter.emit('beforeLaunchApp', { deviceId, bundleId, launchArgs });
+    const adbName = this._getAdbName(deviceId);
+
+    await this.emitter.emit('beforeLaunchApp', { deviceId: adbName, bundleId, launchArgs });
 
-    launchArgs = await this._modifyArgsForNotificationHandling(deviceId, bundleId, launchArgs);
-    await this._launchApp(deviceId, bundleId, launchArgs);
+    launchArgs = await this._modifyArgsForNotificationHandling(adbName, bundleId, launchArgs);
+    await this._launchApp(adbName, bundleId, launchArgs);
 
-    const pid = await this._waitForProcess(deviceId, bundleId);
-    await this.emitter.emit('launchApp', { deviceId, bundleId, launchArgs, pid });
+    const pid = await this._waitForProcess(adbName, bundleId);
+    await this.emitter.emit('launchApp', { deviceId: adbName, bundleId, launchArgs, pid });
     return pid;
   }
 
@@ -118,11 +104,12 @@ class AndroidDriver extends DeviceDriverBase {
       return;
     }
 
-    const {url, detoxUserNotificationDataURL} = params;
+    const adbName = this._getAdbName(deviceId);
+    const { url, detoxUserNotificationDataURL } = params;
     if (url) {
       await this._startActivityWithUrl(url);
     } else if (detoxUserNotificationDataURL) {
-      const payloadPathOnDevice = await this._sendNotificationDataToDevice(detoxUserNotificationDataURL, deviceId);
+      const payloadPathOnDevice = await this._sendNotificationDataToDevice(detoxUserNotificationDataURL, adbName);
       await this._startActivityFromNotification(payloadPathOnDevice);
     }
   }
@@ -144,14 +131,14 @@ class AndroidDriver extends DeviceDriverBase {
   }
 
   async terminate(deviceId, bundleId) {
-    await this.emitter.emit('beforeTerminateApp', { deviceId, bundleId });
+    const adbName = this._getAdbName(deviceId);
+    await this.emitter.emit('beforeTerminateApp', { deviceId: adbName, bundleId });
     await this._terminateInstrumentation();
-    await this.adb.terminate(deviceId, bundleId);
-    await this.emitter.emit('terminateApp', { deviceId, bundleId });
+    await this.adb.terminate(adbName, bundleId);
+    await this.emitter.emit('terminateApp', { deviceId: adbName, bundleId });
   }
 
   async cleanup(deviceId, bundleId) {
-    await this.deviceRegistry.disposeDevice(deviceId);
     await this._terminateInstrumentation();
     await super.cleanup(deviceId, bundleId);
   }
@@ -165,11 +152,13 @@ class AndroidDriver extends DeviceDriverBase {
   }
 
   async reverseTcpPort(deviceId, port) {
-    await this.adb.reverse(deviceId, port);
+    const adbName = this._getAdbName(deviceId);
+    await this.adb.reverse(adbName, port);
   }
 
   async unreverseTcpPort(deviceId, port) {
-    await this.adb.reverseRemove(deviceId, port);
+    const adbName = this._getAdbName(deviceId);
+    await this.adb.reverseRemove(adbName, port);
   }
 
   async setURLBlacklist(urlList) {
@@ -185,14 +174,14 @@ class AndroidDriver extends DeviceDriverBase {
   }
 
   async takeScreenshot(deviceId, screenshotName) {
-    const adb = this.adb;
+    const adbName = this._getAdbName(deviceId);
 
     const pathOnDevice = this.devicePathBuilder.buildTemporaryArtifactPath('.png');
-    await adb.screencap(deviceId, pathOnDevice);
+    await this.adb.screencap(adbName, pathOnDevice);
 
     const tempPath = temporaryPath.for.png();
-    await adb.pull(deviceId, pathOnDevice, tempPath);
-    await adb.rm(deviceId, pathOnDevice);
+    await this.adb.pull(adbName, pathOnDevice, tempPath);
+    await this.adb.rm(adbName, pathOnDevice);
 
     await this.emitter.emit('createExternalArtifact', {
       pluginId: 'screenshot',
@@ -231,10 +220,10 @@ class AndroidDriver extends DeviceDriverBase {
     return testApkPath;
   }
 
-  async _modifyArgsForNotificationHandling(deviceId, bundleId, launchArgs) {
+  async _modifyArgsForNotificationHandling(adbName, bundleId, launchArgs) {
     let _launchArgs = launchArgs;
     if (launchArgs.detoxUserNotificationDataURL) {
-      const notificationPayloadTargetPath = await this._sendNotificationDataToDevice(launchArgs.detoxUserNotificationDataURL, deviceId);
+      const notificationPayloadTargetPath = await this._sendNotificationDataToDevice(launchArgs.detoxUserNotificationDataURL, adbName);
       _launchArgs = {
         ...launchArgs,
         detoxUserNotificationDataURL: notificationPayloadTargetPath,
@@ -243,9 +232,9 @@ class AndroidDriver extends DeviceDriverBase {
     return _launchArgs;
   }
 
-  async _launchApp(deviceId, bundleId, launchArgs) {
+  async _launchApp(adbName, bundleId, launchArgs) {
     if (!this.instrumentation.isRunning()) {
-      await this._launchInstrumentationProcess(deviceId, bundleId, launchArgs);
+      await this._launchInstrumentationProcess(adbName, bundleId, launchArgs);
       await sleep(500);
     } else if (launchArgs.detoxURLOverride) {
       await this._startActivityWithUrl(launchArgs.detoxURLOverride);
@@ -256,15 +245,15 @@ class AndroidDriver extends DeviceDriverBase {
     }
   }
 
-  async _launchInstrumentationProcess(deviceId, bundleId, userLaunchArgs) {
+  async _launchInstrumentationProcess(adbName, bundleId, userLaunchArgs) {
     const serverPort = new URL(this.client.configuration.server).port;
-    await this.adb.reverse(deviceId, serverPort);
+    await this.adb.reverse(adbName, serverPort);
 
     this.instrumentation.setTerminationFn(async () => {
       await this._terminateInstrumentation();
-      await this.adb.reverseRemove(deviceId, serverPort);
+      await this.adb.reverseRemove(adbName, serverPort);
     });
-    await this.instrumentation.launch(deviceId, bundleId, userLaunchArgs);
+    await this.instrumentation.launch(adbName, bundleId, userLaunchArgs);
   }
 
   async _terminateInstrumentation() {
@@ -272,9 +261,9 @@ class AndroidDriver extends DeviceDriverBase {
     await this.instrumentation.setTerminationFn(null);
   }
 
-  async _sendNotificationDataToDevice(dataFileLocalPath, deviceId) {
-    await this.fileXfer.prepareDestinationDir(deviceId);
-    return await this.fileXfer.send(deviceId, dataFileLocalPath, 'notification.json');
+  async _sendNotificationDataToDevice(dataFileLocalPath, adbName) {
+    await this.fileXfer.prepareDestinationDir(adbName);
+    return await this.fileXfer.send(adbName, dataFileLocalPath, 'notification.json');
   }
 
   _startActivityWithUrl(url) {
@@ -289,27 +278,31 @@ class AndroidDriver extends DeviceDriverBase {
     return this.invocationManager.execute(DetoxApi.launchMainActivity());
   }
 
-  async _waitForProcess(deviceId, bundleId) {
+  async _waitForProcess(adbName, bundleId) {
     let pid = NaN;
     try {
-      const queryPid = () => this._queryPID(deviceId, bundleId);
+      const queryPid = () => this._queryPID(adbName, bundleId);
       const retryQueryPid = () => retry({ backoff: 'none', retries: 4 }, queryPid);
       const retryQueryPidMultiple = () => retry({ backoff: 'linear' }, retryQueryPid);
       pid = await retryQueryPidMultiple();
     } catch (e) {
-      log.warn(await this.adb.shell(deviceId, 'ps'));
+      log.warn(await this.adb.shell(adbName, 'ps'));
       throw e;
     }
     return pid;
   }
 
-  async _queryPID(deviceId, bundleId) {
-    const pid = await this.adb.pidof(deviceId, bundleId);
+  async _queryPID(adbName, bundleId) {
+    const pid = await this.adb.pidof(adbName, bundleId);
     if (!pid) {
       throw new Error('PID still not available');
     }
     return pid;
   }
+
+  _getAdbName(deviceId) {
+    return _.isObjectLike(deviceId) ? deviceId.adbName : deviceId;
+  }
 }
 
 module.exports = AndroidDriver;
diff --git a/detox/src/devices/drivers/android/attached/AttachedAndroidDriver.js b/detox/src/devices/drivers/android/attached/AttachedAndroidDriver.js
index a27c56f6f..03b280042 100644
--- a/detox/src/devices/drivers/android/attached/AttachedAndroidDriver.js
+++ b/detox/src/devices/drivers/android/attached/AttachedAndroidDriver.js
@@ -1,13 +1,15 @@
 const _ = require('lodash');
 const AndroidDriver = require('../AndroidDriver');
 const FreeDeviceFinder = require('../tools/FreeDeviceFinder');
+const DeviceRegistry = require('../../../DeviceRegistry');
 
 class AttachedAndroidDriver extends AndroidDriver {
   constructor(config) {
     super(config);
-    
-    this.freeDeviceFinder = new FreeDeviceFinder(this.adb, this.deviceRegistry);
     this._name = 'Unnamed Android Device';
+
+    this._freeDeviceFinder = new FreeDeviceFinder(this.adb, this.deviceRegistry);
+    this._deviceRegistry = DeviceRegistry.forAndroid();
   }
 
   get name() {
@@ -16,8 +18,7 @@ class AttachedAndroidDriver extends AndroidDriver {
 
   async acquireFreeDevice(deviceQuery) {
     const adbNamePattern = _.isPlainObject(deviceQuery) ? deviceQuery.adbName : deviceQuery;
-
-    const adbName = await this.allocateDevice(adbNamePattern);
+    const adbName = await this._deviceRegistry.allocateDevice(() => this._freeDeviceFinder.findFreeDevice(adbNamePattern));
 
     await this.adb.apiLevel(adbName);
     await this.adb.unlockScreen(adbName);
@@ -27,8 +28,9 @@ class AttachedAndroidDriver extends AndroidDriver {
     return adbName;
   }
 
-  async doAllocateDevice(deviceQuery) {
-    return await this.freeDeviceFinder.findFreeDevice(deviceQuery);
+  async cleanup(deviceId, bundleId) {
+    await this._deviceRegistry.disposeDevice(deviceId);
+    await super.cleanup(deviceId, bundleId);
   }
 }
 
diff --git a/detox/src/devices/drivers/android/emulator/EmulatorDeviceAllocator.js b/detox/src/devices/drivers/android/emulator/EmulatorDeviceAllocator.js
new file mode 100644
index 000000000..30ec5e185
--- /dev/null
+++ b/detox/src/devices/drivers/android/emulator/EmulatorDeviceAllocator.js
@@ -0,0 +1,44 @@
+const AndroidDeviceAllocator = require('../AndroidDeviceAllocator');
+const logger = require('../../../../utils/logger').child({ __filename });
+
+const DetoxEmulatorsPortRange = {
+  min: 10000,
+  max: 20000
+};
+
+// TODO unit test
+class EmulatorDeviceAllocator extends AndroidDeviceAllocator {
+  constructor(deviceRegistry, freeDeviceFinder) {
+    super(deviceRegistry, logger);
+    this._freeDeviceFinder = freeDeviceFinder;
+  }
+
+  async _doAllocateDevice(deviceQuery) {
+    let placeholderPort = null;
+    let adbName = null;
+
+    await this.deviceRegistry.allocateDevice(async () => {
+      adbName = await this._freeDeviceFinder.findFreeDevice(deviceQuery);
+      if (!adbName) {
+        placeholderPort = this._allocateEmulatorPlaceholderPort();
+        adbName = `emulator-${placeholderPort}`;
+      }
+      return adbName;
+    });
+
+    return {
+      adbName,
+      placeholderPort,
+      toString: () => adbName,
+    }
+  }
+
+  _allocateEmulatorPlaceholderPort() {
+    const { min, max } = DetoxEmulatorsPortRange;
+    let port = Math.random() * (max - min) + min;
+    port = port & 0xFFFFFFFE; // Should always be even
+    return port;
+  }
+}
+
+module.exports = EmulatorDeviceAllocator;
diff --git a/detox/src/devices/drivers/android/emulator/EmulatorDriver.js b/detox/src/devices/drivers/android/emulator/EmulatorDriver.js
index 47598b96b..00ca1e1a7 100644
--- a/detox/src/devices/drivers/android/emulator/EmulatorDriver.js
+++ b/detox/src/devices/drivers/android/emulator/EmulatorDriver.js
@@ -3,39 +3,39 @@ const fs = require('fs');
 const path = require('path');
 const ini = require('ini');
 const AndroidDriver = require('../AndroidDriver');
-const FreeEmulatorFinder = require('./FreeEmulatorFinder');
+const EmulatorDeviceAllocator = require('./EmulatorDeviceAllocator');
 const AVDValidator = require('./AVDValidator');
 const AVDsResolver = require('./AVDsResolver');
 const EmulatorLauncher = require('./EmulatorLauncher');
 const EmulatorVersionResolver = require('./EmulatorVersionResolver');
+const FreeEmulatorFinder = require('./FreeEmulatorFinder');
 const { EmulatorExec } = require('../exec/EmulatorExec');
 const EmulatorTelnet = require('../tools/EmulatorTelnet');
 const DetoxRuntimeError = require('../../../../errors/DetoxRuntimeError');
+const DeviceRegistry = require('../../../DeviceRegistry');
 const environment = require('../../../../utils/environment');
 const retry = require('../../../../utils/retry');
 const log = require('../../../../utils/logger').child({ __filename });
 const argparse = require('../../../../utils/argparse');
 
-const DetoxEmulatorsPortRange = {
-  min: 10000,
-  max: 20000
-};
-
 const EMU_BIN_STABLE_SKIN_VER = 28;
 
 class EmulatorDriver extends AndroidDriver {
   constructor(config) {
     super(config);
 
+    this._deviceRegistry = DeviceRegistry.forAndroid();
+
     const emulatorExec = new EmulatorExec();
     this._emuVersionResolver = new EmulatorVersionResolver(emulatorExec);
     this._emuLauncher = new EmulatorLauncher(emulatorExec);
 
     const avdsResolver = new AVDsResolver(emulatorExec);
     this._avdValidator = new AVDValidator(avdsResolver, this._emuVersionResolver);
-    this._freeDeviceFinder = new FreeEmulatorFinder(this.adb, this.deviceRegistry);
 
-    this.pendingBoots = {};
+    const freeEmulatorFinder = new FreeEmulatorFinder(this.adb, this._deviceRegistry)
+    this._deviceAllocator = new EmulatorDeviceAllocator(this._deviceRegistry, freeEmulatorFinder);
+
     this._name = 'Unspecified Emulator';
   }
 
@@ -49,9 +49,12 @@ class EmulatorDriver extends AndroidDriver {
     await this._avdValidator.validate(avdName);
     await this._fixEmulatorConfigIniSkinNameIfNeeded(avdName);
 
-    const adbName = await this.allocateDevice(avdName);
+    const {
+      adbName,
+      placeholderPort,
+    } = await this._deviceAllocator.allocateDevice(avdName);
 
-    await this._boot(avdName, adbName);
+    await this._boot(avdName, adbName, placeholderPort);
 
     await this.adb.apiLevel(adbName);
     await this.adb.disableAndroidAnimations(adbName);
@@ -61,11 +64,6 @@ class EmulatorDriver extends AndroidDriver {
     return adbName;
   }
 
-  async doAllocateDevice(deviceQuery) {
-    const freeEmulatorAdbName = await this._freeDeviceFinder.findFreeDevice(deviceQuery);
-    return freeEmulatorAdbName || this._createDevice();
-  }
-
   async installApp(deviceId, _binaryPath, _testBinaryPath) {
     if (argparse.getArgValue('force-adb-install') === 'true') {
       return await super.installApp(deviceId, _binaryPath, _testBinaryPath);
@@ -82,13 +80,10 @@ class EmulatorDriver extends AndroidDriver {
     return this._emuVersionResolver.resolve();
   }
 
-  async _boot(avdName, adbName) {
-    const coldBoot = !!this.pendingBoots[adbName];
-
+  async _boot(avdName, adbName, bootPort) {
+    const coldBoot = !!bootPort;
     if (coldBoot) {
-      const port = this.pendingBoots[adbName];
-      await this._emuLauncher.launch(avdName, { port });
-      delete this.pendingBoots[adbName];
+      await this._emuLauncher.launch(avdName, { port: bootPort });
     }
 
     await this._waitForBootToComplete(adbName);
@@ -107,6 +102,11 @@ class EmulatorDriver extends AndroidDriver {
     });
   }
 
+  async cleanup(deviceId, bundleId) {
+    await this._deviceRegistry.disposeDevice(deviceId);
+    await super.cleanup(deviceId, bundleId);
+  }
+
   async shutdown(deviceId) {
     await this.emitter.emit('beforeShutdownDevice', { deviceId });
     const port = _.split(deviceId, '-')[1];
@@ -147,16 +147,6 @@ class EmulatorDriver extends AndroidDriver {
       fs.writeFileSync(configFile, ini.stringify(config));
     }
   }
-
-  _createDevice() {
-    const {min, max} = DetoxEmulatorsPortRange;
-    let port = Math.random() * (max - min) + min;
-    port = port & 0xFFFFFFFE; // Should always be even
-
-    const adbName = `emulator-${port}`;
-    this.pendingBoots[adbName] = port;
-    return adbName;
-  }
 }
 
 module.exports = EmulatorDriver;
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudDeviceAllocator.js b/detox/src/devices/drivers/android/genycloud/GenyCloudDeviceAllocator.js
new file mode 100644
index 000000000..8195ceed5
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/GenyCloudDeviceAllocator.js
@@ -0,0 +1,72 @@
+const AndroidDeviceAllocator = require('../AndroidDeviceAllocator');
+const retry = require('../../../../utils/retry');
+const logger = require('../../../../utils/logger').child({ __filename });
+
+class GenyCloudDeviceAllocator extends AndroidDeviceAllocator {
+  constructor(deviceRegistry, instanceLookupService, instanceLifecycleService) {
+    super(deviceRegistry, logger);
+
+    this.instanceLookupService = instanceLookupService;
+    this.instanceLifecycleService = instanceLifecycleService;
+  }
+
+  async _doAllocateDevice(recipe) {
+    let { instance, isNew } = await this._doSynchronizedAllocation(recipe);
+
+    instance = await this._waitForInstanceBoot(instance);
+    instance = await this._adbConnectIfNeeded(instance);
+    return {
+      instance,
+      isNew,
+      toString: () => `GenyCloud:${instance.name} (${instance.uuid})`,
+    }
+  }
+
+  async _doSynchronizedAllocation(recipe) {
+    let instance = null;
+    let isNew = false;
+
+    await this.deviceRegistry.allocateDevice(async () => {
+      instance = await this.instanceLookupService.findFreeInstance();
+      if (!instance) {
+        instance = await this.instanceLifecycleService.createInstance(recipe.uuid);
+        isNew = true;
+      }
+      return instance.uuid;
+    });
+
+    return {
+      instance,
+      isNew,
+    }
+  }
+
+  async _waitForInstanceBoot(instance) {
+    if (instance.isOnline()) {
+      return instance;
+    }
+
+    const options = {
+      backoff: 'none',
+      retries: 18,
+      interval: 10000,
+    };
+
+    return await retry(options, async () => {
+      const _instance = await this.instanceLookupService.getInstance(instance.uuid);
+      if (!_instance.isOnline()) {
+        throw new Error(`Timeout waiting for instance ${instance.uuid} to be ready`);
+      }
+      return _instance;
+    });
+  }
+
+  async _adbConnectIfNeeded(instance) {
+    if (!instance.isAdbConnected()) {
+      instance = await this.instanceLifecycleService.adbConnectInstance(instance.uuid);
+    }
+    return instance;
+  }
+}
+
+module.exports = GenyCloudDeviceAllocator;
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js
index 93f3584bc..6de9e956c 100644
--- a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js
+++ b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js
@@ -1,11 +1,11 @@
-const _ = require('lodash');
 const AndroidDriver = require('../AndroidDriver');
+const DeviceRegistry = require('../../../DeviceRegistry');
+const GenyCloudDeviceAllocator = require('./GenyCloudDeviceAllocator');
 const GenyCloudExec = require('./exec/GenyCloudExec');
 const RecipesService = require('./services/GenyRecipesService');
 const InstanceLookupService = require('./services/GenyInstanceLookupService');
 const InstanceLifecycleService = require('./services/GenyInstanceLifecycleService');
 const InstanceNaming = require('./services/GenyInstanceNaming');
-const AllocationHelper = require('./helpers/GenyAllocationHelper');
 const DeviceQueryHelper = require('./helpers/GenyDeviceQueryHelper');
 const DetoxRuntimeError = require('../../../../errors/DetoxRuntimeError');
 const logger = require('../../../../utils/logger').child({ __filename });
@@ -17,11 +17,13 @@ class GenyCloudDriver extends AndroidDriver {
 
     const exec = new GenyCloudExec();
     const instanceNaming = new InstanceNaming(); // TODO should consider a permissive impl for debug/dev mode. Maybe even a custom arg in package.json (Detox > ... > genycloud > sharedAccount: false)
-    this.recipeService = new RecipesService(exec, logger);
-    this.instanceLookupService = new InstanceLookupService(exec, instanceNaming, this.deviceRegistry);
-    this.instanceLifecycleService = new InstanceLifecycleService(exec, instanceNaming);
-    this.deviceQueryHelper = new DeviceQueryHelper(this.recipeService);
-    this.allocationHelper = new AllocationHelper(this.instanceLookupService, this.instanceLifecycleService);
+    this._deviceRegistry = DeviceRegistry.forAndroid();
+
+    const recipeService = new RecipesService(exec, logger);
+    const instanceLookupService = new InstanceLookupService(exec, instanceNaming, this._deviceRegistry);
+    const instanceLifecycleService = new InstanceLifecycleService(exec, instanceNaming);
+    this._deviceQueryHelper = new DeviceQueryHelper(recipeService);
+    this._deviceAllocator = new GenyCloudDeviceAllocator(this._deviceRegistry, instanceLookupService, instanceLifecycleService);
   }
 
   get name() {
@@ -29,31 +31,34 @@ class GenyCloudDriver extends AndroidDriver {
   }
 
   async acquireFreeDevice(deviceQuery) {
-    const recipe = await this.deviceQueryHelper.getRecipeFromQuery(deviceQuery);
-    this.assertRecipe(deviceQuery, recipe);
+    const recipe = await this._deviceQueryHelper.getRecipeFromQuery(deviceQuery);
+    this._assertRecipe(deviceQuery, recipe);
 
-    const cookie = { coldBoot: false };
-    const adbName = await this.allocateDevice({
-      recipe,
-      cookie,
-      toString: () => recipe.toString(),
-    });
+    const { instance, isNew } = await this._deviceAllocator.allocateDevice(recipe);
+    const { adbName, uuid } = instance;
 
-    await this.emitter.emit('bootDevice', { coldBoot: cookie.coldBoot, deviceId: adbName, type: recipe.name});
+    await this.emitter.emit('bootDevice', { coldBoot: isNew, deviceId: adbName, type: recipe.name});
     await this.adb.apiLevel(adbName);
     await this.adb.disableAndroidAnimations(adbName);
 
-    this._name = `GenyCloud-${adbName}`;
-    return adbName;
+    this._name = `GenyCloud:${instance.name} (${uuid} ${adbName})`;
+    return instance;
+  }
+
+  async installApp({ adbName }, _binaryPath, _testBinaryPath) {
+    const {
+      binaryPath,
+      testBinaryPath,
+    } = this._getInstallPaths(_binaryPath, _testBinaryPath);
+    await this.appInstallHelper.install(adbName, binaryPath, testBinaryPath);
   }
 
-  async doAllocateDevice({ recipe, cookie }) {
-    const { instance, coldBooted } = await this.allocationHelper.allocateInstance(recipe.uuid);
-    cookie.coldBoot = coldBooted;
-    return instance.adbName;
+  async cleanup(instance, bundleId) {
+    await this._deviceRegistry.disposeDevice(instance.uuid);
+    await super.cleanup(instance, bundleId);
   }
 
-  assertRecipe(deviceQuery, recipe) {
+  _assertRecipe(deviceQuery, recipe) {
     if (!recipe) {
       throw new DetoxRuntimeError({
         message: 'No Genycloud devices found for recipe!',
diff --git a/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.js b/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.js
index 0fbefbb5d..1755df387 100644
--- a/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.js
+++ b/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.js
@@ -1,4 +1,3 @@
-const _ = require('lodash');
 const exec = require('../../../../../utils/exec').execWithRetriesAndLogs;
 
 class GenyCloudExec {
@@ -7,12 +6,11 @@ class GenyCloudExec {
   }
 
   getInstances() {
-    return this._exec(`instances list`);
+    return this._exec(`instances list -q`);
   }
 
   startInstance(recipeUUID, instanceName) {
-    // TODO should we use --no-wait so as to release workers lock "mutex" asap and then wait-for-boot as we do with google emulators?
-    return this._exec(`instances start --stop-when-inactive ${recipeUUID} "${instanceName}"`);
+    return this._exec(`instances start --stop-when-inactive --no-wait ${recipeUUID} "${instanceName}"`);
   }
 
   adbConnect(instanceUUID) {
diff --git a/detox/src/devices/drivers/android/genycloud/helpers/GenyAllocationHelper.js b/detox/src/devices/drivers/android/genycloud/helpers/GenyAllocationHelper.js
deleted file mode 100644
index 89fa21370..000000000
--- a/detox/src/devices/drivers/android/genycloud/helpers/GenyAllocationHelper.js
+++ /dev/null
@@ -1,28 +0,0 @@
-class GenyAllocationHelper {
-  constructor(instanceLookupService, instanceLifecycleService) {
-    this.instanceLookupService = instanceLookupService;
-    this.instanceLifecycleService = instanceLifecycleService;
-    this.coldBooted = undefined;
-  }
-
-  async allocateInstance(recipeUUID) {
-    let coldBooted = false;
-
-    let instance = await this.instanceLookupService.findFreeInstance(recipeUUID);
-    if (!instance) {
-      instance = await this.instanceLifecycleService.createInstance(recipeUUID);
-      coldBooted = true;
-    }
-
-    if (!instance.isAdbConnected()) {
-      instance = await this.instanceLifecycleService.adbConnectInstance(instance.uuid);
-    }
-
-    return {
-      instance,
-      coldBooted,
-    };
-  }
-}
-
-module.exports = GenyAllocationHelper;
diff --git a/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLookupService.js b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLookupService.js
index 95c1960ee..35f6f75c4 100644
--- a/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLookupService.js
+++ b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLookupService.js
@@ -1,26 +1,32 @@
+const _ = require('lodash');
 const Instance = require('./dto/GenyInstance');
 
 class GenyInstanceLookupService {
-  constructor(genyCloudExec, instanceNaming, deviceRegistry) {
+  constructor(genyCloudExec, instanceNaming, genycloudDeviceRegistry) {
     this.genyCloudExec = genyCloudExec;
     this.instanceNaming = instanceNaming;
-    this.deviceRegistry = deviceRegistry;
+    this.deviceRegistry = genycloudDeviceRegistry;
   }
 
-  async findFreeInstance(recipeUUID) {
-    const freeInstances = await this._getRelevantInstances(recipeUUID);
+  async findFreeInstance() {
+    const freeInstances = await this._getRelevantInstances();
     return (freeInstances[0] || null);
   }
 
-  async _getRelevantInstances(recipeUUID) {
+  async getInstance(instanceUUID) {
+    const instances = await this._getAllInstances();
+    return _.find(instances, (instance) => instance.uuid === instanceUUID);
+  }
+
+  async _getRelevantInstances() {
+    const takenInstanceUUIDs = this.deviceRegistry.getRegisteredDevices();
     const isRelevant = (instance) =>
-      instance.recipeUUID === recipeUUID &&
-      !instance.isTerminated() &&
+      (instance.isOnline() || instance.isInitializing()) &&
       this.instanceNaming.isFamilial(instance.name) &&
-      this._isInstanceFree(instance);
+      !takenInstanceUUIDs.includes(instance.uuid);
 
-    const result = await this._getAllInstances();
-    return result.filter(isRelevant);
+    const instances = await this._getAllInstances();
+    return instances.filter(isRelevant);
   }
 
   async _getAllInstances() {
@@ -28,13 +34,6 @@ class GenyInstanceLookupService {
       .instances
       .map((rawInstance) => new Instance(rawInstance));
   }
-
-  _isInstanceFree(instance) {
-    if (!instance.isAdbConnected()) {
-      return instance;
-    }
-    return !this.deviceRegistry.isDeviceBusy(instance.adbName);
-  }
 }
 
 module.exports = GenyInstanceLookupService;
diff --git a/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.js b/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.js
index f86a19c84..307c03b3a 100644
--- a/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.js
+++ b/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.js
@@ -1,6 +1,16 @@
 const _ = require('lodash');
 const Recipe = require('./GenyRecipe');
 
+const STATE_ONLINE = 'ONLINE';
+const STATE_CREATING = 'CREATING';
+const STATE_BOOTING = 'BOOTING';
+const STATE_STARTING = 'STARTING';
+const initStates = [
+  STATE_CREATING,
+  STATE_BOOTING,
+  STATE_STARTING,
+];
+
 class GenyInstance {
   constructor(rawInstance) {
     Object.assign(this, _.pick(rawInstance, 'uuid', 'name', 'state'));
@@ -15,8 +25,12 @@ class GenyInstance {
     return this.adb.name !== '0.0.0.0';
   }
 
-  isTerminated() {
-    return this.state === 'RECYCLED';
+  isOnline() {
+    return this.state === STATE_ONLINE;
+  }
+
+  isInitializing() {
+    return initStates.includes(this.state);
   }
 
   get recipeName() {
diff --git a/detox/src/devices/drivers/android/tools/AppInstallHelper.js b/detox/src/devices/drivers/android/tools/AppInstallHelper.js
index 1300b9249..e2968fcbf 100644
--- a/detox/src/devices/drivers/android/tools/AppInstallHelper.js
+++ b/detox/src/devices/drivers/android/tools/AppInstallHelper.js
@@ -1,3 +1,5 @@
+// TODO Tweak such that if apk's already exist on the device (need to store uniquely), they will not be resent (would optimize cloud, for example)
+
 class AppInstallHelper {
   constructor(adb, fileXfer) {
     this._adb = adb;
diff --git a/detox/src/devices/drivers/android/tools/FreeDeviceFinder.js b/detox/src/devices/drivers/android/tools/FreeDeviceFinder.js
index 1071e2f95..4d50562ff 100644
--- a/detox/src/devices/drivers/android/tools/FreeDeviceFinder.js
+++ b/detox/src/devices/drivers/android/tools/FreeDeviceFinder.js
@@ -24,9 +24,9 @@ class FreeDeviceFinder {
   async _isDeviceFreeAndMatching(candidate, deviceQuery) {
     const { adbName } = candidate;
 
-    const isBusy = this.deviceRegistry.isDeviceBusy(adbName);
-    if (isBusy) {
-      log.debug({ event: DEVICE_LOOKUP_LOG_EVT }, `Device ${adbName} is busy, skipping...`);
+    const isTaken = this.deviceRegistry.includes(adbName);
+    if (isTaken) {
+      log.debug({ event: DEVICE_LOOKUP_LOG_EVT }, `Device ${adbName} is already taken, skipping...`);
       return false;
     }
 
diff --git a/detox/src/devices/drivers/ios/SimulatorDriver.js b/detox/src/devices/drivers/ios/SimulatorDriver.js
index ba4f8fa2c..a28181d78 100644
--- a/detox/src/devices/drivers/ios/SimulatorDriver.js
+++ b/detox/src/devices/drivers/ios/SimulatorDriver.js
@@ -215,12 +215,8 @@ class SimulatorDriver extends IosDriver {
 
   async _groupDevicesByStatus(deviceQuery) {
     const searchResults = await this._queryDevices(deviceQuery);
-
-    const { busy, free}  = _.groupBy(searchResults, device => {
-      return this.deviceRegistry.isDeviceBusy(device.udid)
-        ? 'busy'
-        : 'free';
-    });
+    const takenDevices = this.deviceRegistry.getRegisteredDevices();
+    const { busy, free }  = _.groupBy(searchResults, ({ udid }) => takenDevices.includes(udid) ? 'busy' : 'free');
 
     const targetOS = _.get(busy, '0.os.identifier');
     const isMatching = targetOS && { os: { identifier: targetOS } };
diff --git a/examples/demo-native-android/package.json b/examples/demo-native-android/package.json
index ac779f870..434f2ab74 100644
--- a/examples/demo-native-android/package.json
+++ b/examples/demo-native-android/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-demo-native-android",
-  "version": "17.11.4",
+  "version": "17.12.0",
   "private": true,
   "scripts": {
     "packager": "react-native start",
@@ -8,7 +8,7 @@
     "e2e": "mocha e2e --opts ./e2e/mocha.opts"
   },
   "devDependencies": {
-    "detox": "^17.11.4",
+    "detox": "^17.12.0",
     "mocha": "^6.1.3"
   },
   "detox": {}
diff --git a/examples/demo-native-ios/package.json b/examples/demo-native-ios/package.json
index 3086f6f67..ad3fdb8e5 100644
--- a/examples/demo-native-ios/package.json
+++ b/examples/demo-native-ios/package.json
@@ -1,9 +1,9 @@
 {
   "name": "detox-demo-native-ios",
-  "version": "17.11.4",
+  "version": "17.12.0",
   "private": true,
   "devDependencies": {
-    "detox": "^17.11.4",
+    "detox": "^17.12.0",
     "mocha": "^6.1.3"
   },
   "detox": {
diff --git a/examples/demo-plugin/package.json b/examples/demo-plugin/package.json
index 374351b9a..ed0fa0d2a 100644
--- a/examples/demo-plugin/package.json
+++ b/examples/demo-plugin/package.json
@@ -1,12 +1,12 @@
 {
   "name": "demo-plugin",
-  "version": "17.11.4",
+  "version": "17.12.0",
   "private": true,
   "scripts": {
     "test:plugin": "detox test --configuration plugin -l verbose"
   },
   "devDependencies": {
-    "detox": "^17.11.4",
+    "detox": "^17.12.0",
     "jest": "^26.5.0"
   },
   "detox": {
diff --git a/examples/demo-react-native-detox-instruments/package.json b/examples/demo-react-native-detox-instruments/package.json
index f3c1db586..a2692f5fb 100644
--- a/examples/demo-react-native-detox-instruments/package.json
+++ b/examples/demo-react-native-detox-instruments/package.json
@@ -1,10 +1,10 @@
 {
   "name": "demo-react-native-detox-instruments",
-  "version": "17.11.4",
+  "version": "17.12.0",
   "private": true,
   "scripts": {},
   "devDependencies": {
-    "detox": "^17.11.4",
+    "detox": "^17.12.0",
     "mocha": "6.x.x"
   },
   "detox": {
diff --git a/examples/demo-react-native-jest/package.json b/examples/demo-react-native-jest/package.json
index 101109f0e..7d1091f80 100644
--- a/examples/demo-react-native-jest/package.json
+++ b/examples/demo-react-native-jest/package.json
@@ -1,6 +1,6 @@
 {
   "name": "demo-react-native-jest",
-  "version": "17.11.4",
+  "version": "17.12.0",
   "private": true,
   "scripts": {
     "test:ios-release": "detox test --configuration ios.sim.release -l verbose",
@@ -9,7 +9,7 @@
     "test:android-release-ci": "detox test --configuration android.emu.release -l verbose --workers 2 --headless --record-logs all --take-screenshots all"
   },
   "devDependencies": {
-    "detox": "^17.11.4",
+    "detox": "^17.12.0",
     "jest": "^26.5.0",
     "jest-circus": "^26.5.2",
     "sanitize-filename": "^1.6.1"
diff --git a/examples/demo-react-native/package.json b/examples/demo-react-native/package.json
index d5fb6fc2c..648555ad6 100644
--- a/examples/demo-react-native/package.json
+++ b/examples/demo-react-native/package.json
@@ -1,6 +1,6 @@
 {
   "name": "example",
-  "version": "17.11.4",
+  "version": "17.12.0",
   "private": true,
   "scripts": {
     "start": "react-native start",
@@ -22,7 +22,7 @@
     "react-native": "0.62.x"
   },
   "devDependencies": {
-    "detox": "^17.11.4",
+    "detox": "^17.12.0",
     "mocha": "^6.1.3"
   }
 }
diff --git a/lerna.json b/lerna.json
index 93d4f0315..b0f16f6e8 100644
--- a/lerna.json
+++ b/lerna.json
@@ -13,7 +13,7 @@
     "generation",
     "."
   ],
-  "version": "17.11.4",
+  "version": "17.12.0",
   "npmClient": "npm",
   "command": {
     "publish": {
diff --git a/package.json b/package.json
index ad7c7fc15..21e60e54c 100644
--- a/package.json
+++ b/package.json
@@ -19,5 +19,5 @@
     "semver": "5.x.x",
     "shell-utils": "1.x.x"
   },
-  "version": "17.11.4"
+  "version": "17.12.0"
 }
diff --git a/scripts/ci.ios.sh b/scripts/ci.ios.sh
index dd514c4cd..4d9373dad 100755
--- a/scripts/ci.ios.sh
+++ b/scripts/ci.ios.sh
@@ -2,8 +2,6 @@
 
 source $(dirname "$0")/ci.sh
 
-run_f "$(dirname "$0")/unit.ios.sh"
-
 mkdir -p coverage
 
 pushd detox/test


Test patch:
diff --git a/detox/src/devices/DeviceRegistry.test.js b/detox/src/devices/DeviceRegistry.test.js
index 080d9425b..399446b57 100644
--- a/detox/src/devices/DeviceRegistry.test.js
+++ b/detox/src/devices/DeviceRegistry.test.js
@@ -19,33 +19,72 @@ describe('DeviceRegistry', () => {
       await fs.remove(lockfilePath);
     });
 
-    it('should throw on attempt to checking if device is busy outside of allocation/disposal context', async () => {
+    async function allocateDevice(deviceId) {
+      return registry.allocateDevice(() => deviceId);
+    }
+
+    function expectRegisteredDevices(...deviceIds) {
+      return registry.allocateDevice(() => {
+        expect(registry.getRegisteredDevices()).toEqual([ ...deviceIds ]);
+        throw new Error('ignored'); // So it wouldn't really allocate anything
+      }).catch((e) => { if (e.message !== 'ignored') throw e });
+    }
+
+    async function checkDeviceRegisteredAndDispose(deviceId) {
+      return registry.disposeDevice(async () => {
+        expect(registry.includes(deviceId)).toBe(true);
+        return deviceId;
+      });
+    }
+
+    async function checkDeviceNotRegistered(deviceId) {
+      return registry.allocateDevice(async () => {
+        expect(registry.includes(deviceId)).toBe(false);
+        throw new Error('ignored'); // So it wouldn't really allocate anything
+      }).catch((e) => { if (e.message !== 'ignored') throw e });
+    }
+
+    const assertForbiddenOutOfContextRegistryQuery = () =>
+      expect(() => registry.includes('whatever')).toThrowError();
+
+    const assertForbiddenOutOfContextDeviceListQuery = () =>
+      expect(() => registry.getRegisteredDevices()).toThrowError();
+
+    it('should be able to tell whether a device is registered', async () => {
       const deviceId = 'emulator-5554';
+      await allocateDevice(deviceId);
+      await checkDeviceRegisteredAndDispose(deviceId);
+      await checkDeviceNotRegistered(deviceId);
+    });
 
-      const assertForbiddenOutOfContext = () =>
-        expect(() => registry.isDeviceBusy(deviceId)).toThrowError();
+    it('should throw on attempt of checking whether a device is registered outside of allocation/disposal context', async () => {
+      const deviceId = 'emulator-5554';
 
-      assertForbiddenOutOfContext();
-      const result = await registry.allocateDevice(() => {
-        expect(registry.isDeviceBusy(deviceId)).toBe(false);
-        return deviceId;
-      });
+      assertForbiddenOutOfContextRegistryQuery();
 
-      expect(result).toBe(deviceId);
+      await allocateDevice(deviceId);
+      assertForbiddenOutOfContextRegistryQuery();
+    });
 
-      assertForbiddenOutOfContext();
-      await registry.disposeDevice(() => {
-        expect(registry.isDeviceBusy(deviceId)).toBe(true);
-        return deviceId;
-      });
+    it('should be able to fast-get a valid list of registered devices', async () => {
+      const deviceId = 'emulator-5554';
+      const anotherDeviceId = {
+        type: 'mocked-device-type',
+        adbName: 'emulator-5556',
+      };
+
+      await allocateDevice(deviceId);
+      await allocateDevice(anotherDeviceId);
+      await expectRegisteredDevices(deviceId, anotherDeviceId);
+    });
+
+    it('should throw on attempt of fast-getting registered devices list outside of allocation/disposal context', async () => {
+      const deviceId = 'emulator-5554';
 
-      assertForbiddenOutOfContext();
-      await registry.allocateDevice(() => {
-        expect(registry.isDeviceBusy(deviceId)).toBe(false);
-        throw new Error();
-      }).catch(() => {});
+      assertForbiddenOutOfContextDeviceListQuery();
 
-      assertForbiddenOutOfContext();
+      await allocateDevice(deviceId);
+      assertForbiddenOutOfContextDeviceListQuery();
     });
 
     describe('.reset() method', () => {
@@ -63,7 +102,7 @@ describe('DeviceRegistry', () => {
     })
   });
 
-  describe('static methods', () => {
+  describe('instantiation methods', () => {
     let ExclusiveLockFile;
 
     beforeEach(() => {
@@ -72,7 +111,7 @@ describe('DeviceRegistry', () => {
       DeviceRegistry = require('./DeviceRegistry');
     });
 
-    it('should expose static convenience method DeviceRegistry.forIOS()', () => {
+    it('should expose method for iOS-lock-based method', () => {
       expect(DeviceRegistry.forIOS()).toBeInstanceOf(DeviceRegistry);
       expect(ExclusiveLockFile).toHaveBeenCalledWith(
         environment.getDeviceLockFilePathIOS(),
@@ -80,7 +119,7 @@ describe('DeviceRegistry', () => {
       );
     });
 
-    it('should expose static convenience method DeviceRegistry.forAndroid()', async () => {
+    it('should expose method for Android-lock-based method', () => {
       expect(DeviceRegistry.forAndroid()).toBeInstanceOf(DeviceRegistry);
       expect(ExclusiveLockFile).toHaveBeenCalledWith(
         environment.getDeviceLockFilePathAndroid(),
@@ -88,4 +127,4 @@ describe('DeviceRegistry', () => {
       );
     });
   });
-});
\ No newline at end of file
+});
diff --git a/detox/src/devices/drivers/android/AndroidDriver.test.js b/detox/src/devices/drivers/android/AndroidDriver.test.js
index b14abe441..38495bb53 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.test.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.test.js
@@ -29,7 +29,6 @@ describe('Android driver', () => {
   const fileXferObj = () => latestInstanceOf(FileXferClass);
   const appInstallHelperObj = () => latestInstanceOf(AppInstallHelperClass);
   const instrumentationObj = () => latestInstanceOf(InstrumentationClass);
-  const deviceRegistryObj = () => latestInstanceOf(DeviceRegistryClass);
 
   let uut;
   beforeEach(() => {
@@ -133,10 +132,6 @@ describe('Android driver', () => {
 
     it('should turn off the events emitter', () =>
       expect(emitter.off).toHaveBeenCalled());
-
-    it('should dispose device', () => {
-      expect(deviceRegistryObj().disposeDevice).toHaveBeenCalledWith(deviceId);
-    });
   });
 
   describe('URL runtime delivery handling', () => {
diff --git a/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.test.js b/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.test.js
index 79ab72225..d799ffb94 100644
--- a/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.test.js
+++ b/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.test.js
@@ -8,7 +8,7 @@ describe('FreeEmulatorFinder', () => {
   beforeEach(() => {
     const DeviceRegistry = jest.genMockFromModule('../../../DeviceRegistry');
     mockDeviceRegistry = new DeviceRegistry();
-    mockDeviceRegistry.isDeviceBusy.mockReturnValue(false);
+    mockDeviceRegistry.includes.mockReturnValue(false);
 
     const FreeEmulatorFinder = require('./FreeEmulatorFinder');
     uut = new FreeEmulatorFinder(mockAdb, mockDeviceRegistry);
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudDeviceAllocator.test.js b/detox/src/devices/drivers/android/genycloud/GenyCloudDeviceAllocator.test.js
new file mode 100644
index 000000000..5bb7276a9
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/GenyCloudDeviceAllocator.test.js
@@ -0,0 +1,234 @@
+describe('Genymotion-Cloud device allocator', () => {
+  const recipeUUID = 'mock-recipe-uuid';
+
+  let logger;
+  let retry;
+  let deviceRegistry;
+  let instanceLookupService;
+  let instanceLifecycleService;
+  let GenyInstance;
+  let uut;
+  beforeEach(() => {
+    jest.mock('../../../../utils/logger');
+    logger = require('../../../../utils/logger');
+
+    jest.mock('../../../../utils/retry');
+    retry = require('../../../../utils/retry');
+    retry.mockImplementation((options, func) => func());
+
+    const DeviceRegistry = jest.genMockFromModule('../../../../devices/DeviceRegistry');
+    deviceRegistry = new DeviceRegistry();
+    deviceRegistry.allocateDevice.mockImplementation((func) => func());
+
+    const InstanceLookupService = jest.genMockFromModule('./services/GenyInstanceLookupService');
+    instanceLookupService = new InstanceLookupService();
+
+    const InstanceLifecycleService = jest.genMockFromModule('./services/GenyInstanceLifecycleService');
+    instanceLifecycleService = new InstanceLifecycleService();
+
+    GenyInstance = jest.genMockFromModule('./services/dto/GenyInstance');
+
+    const DeviceAllocator = require('./GenyCloudDeviceAllocator');
+    uut = new DeviceAllocator(deviceRegistry, instanceLookupService, instanceLifecycleService);
+  });
+
+  const aRecipe = () => ({
+    uuid: recipeUUID,
+    toString: () => 'mock-recipe-toString()',
+  });
+
+  const anOfflineInstance = () => {
+    const instance = new GenyInstance();
+    instance.uuid = 'mock-instance-uuid';
+    instance.isAdbConnected.mockReturnValue(false);
+    instance.isOnline.mockReturnValue(false);
+    instance.adbName = '0.0.0.0';
+    return instance;
+  };
+
+  const anOnlineInstance = () => {
+    const instance = anOfflineInstance();
+    instance.isOnline.mockReturnValue(true);
+    return instance;
+  };
+
+  const aFullyConnectedInstance = () => {
+    const instance = anOnlineInstance();
+    instance.isAdbConnected.mockReturnValue(true);
+    instance.adbName = 'localhost:1234';
+    return instance;
+  };
+
+  const aDisconnectedInstance = () => {
+    const instance = anOnlineInstance();
+    instance.isAdbConnected.mockReturnValue(false);
+    return instance;
+  };
+
+  const givenFreeInstance = (instance) => instanceLookupService.findFreeInstance.mockResolvedValueOnce(instance)
+  const givenNoFreeInstances = () => instanceLookupService.findFreeInstance.mockResolvedValue(undefined);
+  const givenCreatedInstance = (instance) => instanceLifecycleService.createInstance.mockResolvedValueOnce(instance);
+  const givenConnectionInstance = (instance) => instanceLifecycleService.adbConnectInstance.mockResolvedValue(instance);
+  const givenRefreshedInstance = (instance) => instanceLookupService.getInstance.mockReturnValueOnce(instance);
+
+  it('should return a free device', async () => {
+    const connectedInstance = aFullyConnectedInstance();
+    givenFreeInstance(connectedInstance);
+    givenConnectionInstance(connectedInstance);
+
+    const result = await uut.allocateDevice(aRecipe());
+    expect(result.instance).toEqual(connectedInstance);
+    expect(instanceLookupService.findFreeInstance).toHaveBeenCalledWith();
+  });
+
+  it('should adb-connect device if disconnected', async () => {
+    const connectedInstance = aFullyConnectedInstance();
+    const disconnectedInstance = aDisconnectedInstance();
+    givenFreeInstance(disconnectedInstance);
+    givenConnectionInstance(connectedInstance);
+
+    const result = await uut.allocateDevice(aRecipe());
+    expect(result.instance).toEqual(connectedInstance);
+
+    expect(instanceLifecycleService.adbConnectInstance).toHaveBeenCalledWith(disconnectedInstance.uuid);
+  });
+
+  it('should not connect a connected device', async () => {
+    const connectedInstance = aFullyConnectedInstance();
+    givenFreeInstance(connectedInstance);
+
+    await uut.allocateDevice(aRecipe());
+
+    expect(instanceLifecycleService.adbConnectInstance).not.toHaveBeenCalled();
+  });
+
+  it('should not connect inside locking callback (!!!)', async () => {
+    const connectedInstance = aFullyConnectedInstance();
+    const disconnectedInstance = aDisconnectedInstance();
+    givenFreeInstance(disconnectedInstance);
+    givenConnectionInstance(connectedInstance);
+
+    deviceRegistry.allocateDevice.mockImplementation(async (func) => {
+      const result = await func();
+      expect(instanceLifecycleService.adbConnectInstance).not.toHaveBeenCalled();
+      return result;
+    });
+
+    await uut.allocateDevice(aRecipe());
+  });
+
+  it('should create a device if no free one is available', async () => {
+    const instance = aFullyConnectedInstance();
+    givenNoFreeInstances();
+    givenCreatedInstance(instance);
+
+    const result = await uut.allocateDevice(aRecipe());
+    expect(result.instance).toEqual(instance);
+    expect(instanceLifecycleService.createInstance).toHaveBeenCalledWith(recipeUUID);
+  });
+
+  it('should wait for a created device to become online', async () => {
+    const instance = anOfflineInstance();
+    const onlineInstance = aFullyConnectedInstance();
+    givenNoFreeInstances();
+    givenCreatedInstance(instance);
+    givenRefreshedInstance(onlineInstance);
+    givenConnectionInstance(onlineInstance);
+
+    retry.mockImplementationOnce(async (options, func) => {
+      const instance = await func();
+      expect(instanceLookupService.getInstance).toHaveBeenCalledWith(instance.uuid);
+      return instance;
+    });
+
+    const result = await uut.allocateDevice(aRecipe());
+    expect(result.instance).toEqual(onlineInstance);
+    expect(retry).toHaveBeenCalled();
+  });
+
+  it('should fail if device never becomes online', async () => {
+    const instance = anOfflineInstance();
+    givenNoFreeInstances();
+    givenCreatedInstance(instance);
+    givenRefreshedInstance(instance);
+
+    try {
+      await uut.allocateDevice(aRecipe());
+      fail('Expected an error');
+    } catch (e) {
+      expect(e.toString()).toContain(`Timeout waiting for instance ${instance.uuid} to be ready`);
+    }
+  });
+
+  it('should not wait for created device to become online if already online', async () => {
+    const instance = aFullyConnectedInstance();
+    givenNoFreeInstances();
+    givenCreatedInstance(instance);
+    givenRefreshedInstance(instance);
+
+    await uut.allocateDevice(aRecipe());
+    expect(instanceLookupService.getInstance).not.toHaveBeenCalled();
+    expect(retry).not.toHaveBeenCalled();
+  });
+
+  it('should not wait for device to become online, inside locking callback (!!!)', async () => {
+    const instance = anOfflineInstance();
+    const onlineInstance = aFullyConnectedInstance();
+    givenNoFreeInstances();
+    givenCreatedInstance(instance);
+    givenRefreshedInstance(onlineInstance);
+
+    deviceRegistry.allocateDevice.mockImplementation(async (func) => {
+      const result = await func();
+      expect(instanceLookupService.getInstance).not.toHaveBeenCalled();
+      return result;
+    });
+
+    await uut.allocateDevice(aRecipe());
+  });
+
+  it('should return isNew=true if new device was allocated', async () => {
+    const instance = aFullyConnectedInstance();
+    givenNoFreeInstances();
+    givenCreatedInstance(instance);
+
+    const result = await uut.allocateDevice(aRecipe());
+    expect(result.isNew).toEqual(true);
+  });
+
+  it('should return isNew=false if a device was reused', async () => {
+    const instance = aFullyConnectedInstance();
+    givenFreeInstance(instance);
+
+    const result = await uut.allocateDevice(aRecipe());
+    expect(result.isNew).toEqual(false);
+  });
+
+  it('should allocate device based on the instance\'s UUID', async () => {
+    const instance = aFullyConnectedInstance();
+    givenFreeInstance(instance);
+
+    deviceRegistry.allocateDevice.mockImplementation(async (func) => {
+      const result = await func();
+      expect(result).toEqual(instance.uuid);
+      return result;
+    });
+
+    await uut.allocateDevice(aRecipe());
+    expect(deviceRegistry.allocateDevice).toHaveBeenCalled();
+  });
+
+  it('should log pre-allocate event', async () => {
+    givenFreeInstance(aFullyConnectedInstance());
+    await uut.allocateDevice(aRecipe());
+    expect(logger.debug).toHaveBeenCalledWith({ event: 'ALLOCATE_DEVICE' }, expect.stringContaining('Trying to allocate'));
+    expect(logger.debug).toHaveBeenCalledWith({ event: 'ALLOCATE_DEVICE' }, expect.stringContaining('mock-recipe-toString()'));
+  });
+
+  it('should log post-allocate event', async () => {
+    const instance = aFullyConnectedInstance();
+    givenFreeInstance(instance);
+    await uut.allocateDevice(aRecipe());
+    expect(logger.debug).toHaveBeenCalledWith({ event: 'ALLOCATE_DEVICE' }, expect.stringContaining(`Settled on GenyCloud:${instance.name} (${instance.uuid})`));
+  });
+});
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js
index c7d094896..65b1cb9d3 100644
--- a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js
+++ b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js
@@ -21,6 +21,7 @@ const mockDirectDependencies = () => {
   jest.mock('./services/GenyInstanceLookupService');
   jest.mock('./services/GenyInstanceLifecycleService');
   jest.mock('./services/GenyInstanceNaming');
+  jest.mock('../../../../utils/getAbsoluteBinaryPath');
 };
 
 const aRecipe = () => ({
@@ -45,7 +46,9 @@ describe('Genymotion-cloud driver', () => {
   let emitter;
   let adb;
   let deviceQueryHelper;
-  let allocationHelper;
+  let deviceRegistry;
+  let deviceAllocator;
+  let GenyCloudDriver;
   let uut;
   beforeEach(() => {
     const Emitter = jest.genMockFromModule('../../../../utils/AsyncEmitter');
@@ -58,7 +61,7 @@ describe('Genymotion-cloud driver', () => {
     adb = () => latestInstanceOf(ADB);
 
     const DeviceRegistry = require('../../../DeviceRegistry');
-    const deviceRegistry = new DeviceRegistry();
+    deviceRegistry = new DeviceRegistry();
     deviceRegistry.allocateDevice.mockImplementation((doAllocateFn) => doAllocateFn());
     DeviceRegistry.forAndroid.mockReturnValue(deviceRegistry);
 
@@ -66,11 +69,11 @@ describe('Genymotion-cloud driver', () => {
     const DeviceQueryHelper = require('./helpers/GenyDeviceQueryHelper');
     deviceQueryHelper = () => latestInstanceOf(DeviceQueryHelper);
 
-    jest.mock('./helpers/GenyAllocationHelper');
-    const AllocationHelper = require('./helpers/GenyAllocationHelper');
-    allocationHelper = () => latestInstanceOf(AllocationHelper);
+    jest.mock('./GenyCloudDeviceAllocator');
+    const DeviceAllocator = require('./GenyCloudDeviceAllocator');
+    deviceAllocator = () => latestInstanceOf(DeviceAllocator);
 
-    const GenyCloudDriver = require('./GenyCloudDriver');
+    GenyCloudDriver = require('./GenyCloudDriver');
     uut = new GenyCloudDriver({
       invocationManager,
       emitter,
@@ -83,32 +86,35 @@ describe('Genymotion-cloud driver', () => {
   });
 
   describe('device (instance) allocation', () => {
-    const givenNoReceipes = () => deviceQueryHelper().getRecipeFromQuery.mockResolvedValue(null);
+    const givenNoRecipes = () => deviceQueryHelper().getRecipeFromQuery.mockResolvedValue(null);
     const givenResolvedRecipeForQuery = (recipe) => deviceQueryHelper().getRecipeFromQuery.mockResolvedValue(recipe);
-    const givenInstanceAllocationResult = ({ instance, coldBooted = false }) => allocationHelper().allocateInstance.mockResolvedValue({ instance, coldBooted });
+    const givenDeviceAllocationResult = ({ instance, isNew = false }) => deviceAllocator().allocateDevice.mockResolvedValue({ instance, isNew });
 
     it('should get a recipe and allocate a device', async () => {
       const recipe = aRecipe();
       const instance = anInstance();
       givenResolvedRecipeForQuery(recipe);
-      givenInstanceAllocationResult({ instance });
+      givenDeviceAllocationResult({ instance });
 
       const deviceQuery = aDeviceQuery();
       const result = await uut.acquireFreeDevice(deviceQuery);
 
-      expect(result).toEqual(instance.adbName);
+      expect(result).toEqual({
+        adbName: instance.adbName,
+        uuid: instance.uuid,
+      });
       expect(deviceQueryHelper().getRecipeFromQuery).toHaveBeenCalledWith(deviceQuery);
-      expect(allocationHelper().allocateInstance).toHaveBeenCalledWith(recipe.uuid);
+      expect(deviceAllocator().allocateDevice).toHaveBeenCalledWith(recipe);
     });
 
     it('should throw a descriptive error recipe not found', async () => {
       const deviceQuery = aDeviceQuery();
-      givenNoReceipes();
-      givenInstanceAllocationResult({ instance: anInstance() });
+      givenNoRecipes();
+      givenDeviceAllocationResult({ instance: anInstance() });
 
       try {
         await uut.acquireFreeDevice(deviceQuery);
-        fail('Excpected an error');
+        fail('Expected an error');
       } catch (e) {
         expect(e.toString()).toContain('No Genycloud devices found for recipe!');
         expect(e.toString()).toContain('HINT: Check that your Genycloud account has a template associated with your Detox device configuration: ' + JSON.stringify(deviceQuery));
@@ -119,7 +125,7 @@ describe('Genymotion-cloud driver', () => {
       const recipe = aRecipe();
       const instance = anInstance();
       givenResolvedRecipeForQuery(recipe);
-      givenInstanceAllocationResult({ instance });
+      givenDeviceAllocationResult({ instance });
 
       await uut.acquireFreeDevice(aDeviceQuery());
 
@@ -130,7 +136,7 @@ describe('Genymotion-cloud driver', () => {
       const recipe = aRecipe();
       const instance = anInstance();
       givenResolvedRecipeForQuery(recipe);
-      givenInstanceAllocationResult({ instance });
+      givenDeviceAllocationResult({ instance });
 
       const error = new Error('mocked error');
       try {
@@ -146,7 +152,7 @@ describe('Genymotion-cloud driver', () => {
       const recipe = aRecipe();
       const instance = anInstance();
       givenResolvedRecipeForQuery(recipe);
-      givenInstanceAllocationResult({ instance, coldBooted: true });
+      givenDeviceAllocationResult({ instance, isNew: true });
 
       await uut.acquireFreeDevice(aDeviceQuery());
 
@@ -156,17 +162,17 @@ describe('Genymotion-cloud driver', () => {
     it('should return a descriptive device name in post-allocation state', async () => {
       const instance = anInstance();
       givenResolvedRecipeForQuery(aRecipe());
-      givenInstanceAllocationResult({ instance });
+      givenDeviceAllocationResult({ instance });
 
       await uut.acquireFreeDevice(aDeviceQuery());
 
-      expect(uut.name).toEqual(`GenyCloud-${instance.adbName}`);
+      expect(uut.name).toEqual(`GenyCloud:${instance.name} (${instance.uuid} ${instance.adbName})`);
     });
 
-    it('should init ADb\'s API-level', async () => {
+    it('should init ADB\'s API-level', async () => {
       const instance = anInstance();
       givenResolvedRecipeForQuery(aRecipe());
-      givenInstanceAllocationResult({ instance });
+      givenDeviceAllocationResult({ instance });
 
       await uut.acquireFreeDevice(aDeviceQuery());
 
@@ -176,11 +182,52 @@ describe('Genymotion-cloud driver', () => {
     it('should disable native animations', async () => {
       const instance = anInstance();
       givenResolvedRecipeForQuery(aRecipe());
-      givenInstanceAllocationResult({ instance });
+      givenDeviceAllocationResult({ instance });
 
       await uut.acquireFreeDevice(aDeviceQuery());
 
       expect(adb().disableAndroidAnimations).toHaveBeenCalledWith(instance.adbName);
     });
   });
+
+  describe('app installation', () => {
+    const appInstallHelperObj = () => latestInstanceOf(AppInstallHelperClass);
+
+    let AppInstallHelperClass;
+    let getAbsoluteBinaryPath;
+    beforeEach(() => {
+      AppInstallHelperClass = require('../tools/AppInstallHelper');
+      getAbsoluteBinaryPath = require('../../../../utils/getAbsoluteBinaryPath');
+    });
+
+    it('should install using install helper', async () => {
+      getAbsoluteBinaryPath
+        .mockReturnValueOnce('bin-install-path')
+        .mockReturnValueOnce('testbin-install-path');
+
+      const deviceId = anInstance();
+      await uut.installApp(deviceId, 'bin-path', 'testbin-path');
+      expect(appInstallHelperObj().install).toHaveBeenCalledWith(deviceId.adbName, 'bin-install-path', 'testbin-install-path');
+    });
+  });
+
+  describe('Cleanup', () => {
+    const instrumentationObj = () => latestInstanceOf(Instrumentation);
+
+    let Instrumentation;
+    beforeEach(() => {
+      Instrumentation = require('../tools/MonitoredInstrumentation');
+    });
+
+    it('should dispose an instance based on its UUID', async () => {
+      const instance = anInstance();
+      await uut.cleanup(instance, 'bundle-id');
+      expect(deviceRegistry.disposeDevice).toHaveBeenCalledWith(instance.uuid);
+    });
+
+    it('should kill instrumentation', async () => {
+      await uut.cleanup(anInstance(), 'bundle-id');
+      expect(instrumentationObj().terminate).toHaveBeenCalled();
+    });
+  });
 });
diff --git a/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.test.js b/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.test.js
index 4504ce6c9..980944bb1 100644
--- a/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.test.js
+++ b/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.test.js
@@ -46,12 +46,12 @@ describe('Genymotion-cloud executable', () => {
     {
       commandName: 'Get Instances',
       commandExecFn: () => uut.getInstances(),
-      expectedExec: `"gmsaas" --format compactjson instances list`,
+      expectedExec: `"gmsaas" --format compactjson instances list -q`,
     },
     {
       commandName: 'Start Instance',
       commandExecFn: () => uut.startInstance(recipeUUID, instanceName),
-      expectedExec: `"gmsaas" --format compactjson instances start --stop-when-inactive ${recipeUUID} "${instanceName}"`,
+      expectedExec: `"gmsaas" --format compactjson instances start --stop-when-inactive --no-wait ${recipeUUID} "${instanceName}"`,
     },
     {
       commandName: 'ADB Connect',
diff --git a/detox/src/devices/drivers/android/genycloud/helpers/GenyAllocationHelper.test.js b/detox/src/devices/drivers/android/genycloud/helpers/GenyAllocationHelper.test.js
deleted file mode 100644
index 7b6677b8e..000000000
--- a/detox/src/devices/drivers/android/genycloud/helpers/GenyAllocationHelper.test.js
+++ /dev/null
@@ -1,121 +0,0 @@
-describe('Genymotion-cloud instance allocation helper', () => {
-  const recipeUUID = 'mock-recipe-uuid';
-
-  let instanceLookupService;
-  let instanceLifecycleService;
-  let GenyInstance;
-  let uut;
-  beforeEach(() => {
-    const InstanceLookupService = jest.genMockFromModule('../services/GenyInstanceLookupService');
-    instanceLookupService = new InstanceLookupService();
-
-    const InstanceLifecycleService = jest.genMockFromModule('../services/GenyInstanceLifecycleService');
-    instanceLifecycleService = new InstanceLifecycleService();
-
-    GenyInstance = jest.genMockFromModule('../services/dto/GenyInstance');
-
-    const GenyAllocationHelper = require('./GenyAllocationHelper');
-    uut = new GenyAllocationHelper(instanceLookupService, instanceLifecycleService);
-  });
-
-  const aConnectedInstance = () => {
-    const instance = new GenyInstance();
-    instance.uuid = 'mock-instance-uuid';
-    instance.isAdbConnected.mockReturnValue(true);
-    instance.adbName = 'mock-adb-name';
-    return instance;
-  };
-
-  const aDisconnectedInstance = () => {
-    const instance = new GenyInstance();
-    instance.uuid = 'mock-instance-uuid';
-    instance.isAdbConnected.mockReturnValue(false);
-    instance.adbName = '0.0.0.0';
-    return instance;
-  };
-
-  const givenFreeInstance = (instance) => instanceLookupService.findFreeInstance.mockResolvedValueOnce(instance)
-  const givenNoFreeInstances = () => instanceLookupService.findFreeInstance.mockResolvedValue(undefined);
-  const givenCreatedInstance = (instance) => instanceLifecycleService.createInstance.mockResolvedValueOnce(instance);
-  const givenConnectionInstance = (instance) => instanceLifecycleService.adbConnectInstance.mockResolvedValue(instance);
-  const givenConnectError = (error) => instanceLifecycleService.adbConnectInstance.mockRejectedValue(error);
-
-  it('should find and return a ready-to-use instance', async () => {
-    const connectedInstance = aConnectedInstance();
-    givenFreeInstance(connectedInstance);
-
-    const result = await uut.allocateInstance(recipeUUID);
-    expect(result.instance).toEqual(connectedInstance);
-    expect(instanceLookupService.findFreeInstance).toHaveBeenCalledWith(recipeUUID);
-  });
-
-  it('should create an instance if no such is available', async () => {
-    const connectedInstance = aConnectedInstance();
-    givenNoFreeInstances();
-    givenCreatedInstance(connectedInstance);
-
-    const result = await uut.allocateInstance(recipeUUID);
-    expect(result.instance).toEqual(connectedInstance);
-    expect(instanceLifecycleService.createInstance).toHaveBeenCalledWith(recipeUUID);
-  });
-
-  it('should adb-connect a disconnected, newly-created instance', async () => {
-    const connectedInstance = aConnectedInstance();
-    const disconnectedInstance = aDisconnectedInstance();
-    givenNoFreeInstances();
-    givenCreatedInstance(disconnectedInstance);
-    givenConnectionInstance(connectedInstance);
-
-    const result = await uut.allocateInstance(recipeUUID);
-    expect(result.instance).toEqual(connectedInstance);
-    expect(instanceLifecycleService.adbConnectInstance).toHaveBeenCalledWith(disconnectedInstance.uuid);
-  });
-
-  it('should adb-connect a disconnected, free (preexisting) instance', async () => {
-    const connectedInstance = aConnectedInstance();
-    const disconnectedInstance = aDisconnectedInstance();
-    givenFreeInstance(disconnectedInstance);
-    givenConnectionInstance(connectedInstance);
-
-    const result = await uut.allocateInstance(recipeUUID);
-    expect(result.instance).toEqual(connectedInstance);
-    expect(instanceLifecycleService.adbConnectInstance).toHaveBeenCalledWith(disconnectedInstance.uuid);
-  });
-
-  it('should throw if adb-connect fails', async () => {
-    const error = new Error('mocked adb-connect failure');
-    const disconnectedInstance = aDisconnectedInstance();
-    givenFreeInstance(disconnectedInstance);
-    givenConnectError(error);
-
-    try {
-      await uut.allocateInstance(recipeUUID);
-      fail('Expected an error');
-    } catch(e) {
-      expect(e).toEqual(error);
-    }
-  });
-
-  it('should not adb-connect an already-connected instance', async () => {
-    const connectedInstance = aConnectedInstance('mock-adb-name');
-    givenFreeInstance(connectedInstance);
-
-    await uut.allocateInstance(recipeUUID);
-    expect(instanceLifecycleService.adbConnectInstance).not.toHaveBeenCalled();
-  });
-
-  it('should indicate no cold-booting took place', async () => {
-    givenFreeInstance(aConnectedInstance());
-
-    const result = await uut.allocateInstance(recipeUUID);
-    expect(result.coldBooted).toEqual(false);
-  });
-
-  it('should indicate cold-booting took place if new instance created', async () => {
-    givenNoFreeInstances();
-    givenCreatedInstance(aConnectedInstance());
-
-    const result = await uut.allocateInstance(recipeUUID);
-    expect(result.coldBooted).toEqual(true);
-  });
-});
diff --git a/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLookupService.test.js b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLookupService.test.js
index bb581bb75..ef14904ac 100644
--- a/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLookupService.test.js
+++ b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLookupService.test.js
@@ -1,18 +1,4 @@
 describe('Genymotion-Cloud instances lookup service', () => {
-  const INSTANCE_STATE_ONLINE = 'ONLINE';
-  const INSTANCE_STATE_TERMINATED = 'RECYCLED';
-
-  const anInstance = () => ({
-    uuid: 'mock-instance-uuid',
-    name: 'mock-instance-name',
-    adb_serial: 'mock-serial:1111',
-    state: INSTANCE_STATE_ONLINE,
-    recipe: {
-      name: 'mock-recipe-name',
-      uuid: 'mock-recipe-uuid',
-    }
-  });
-
   let exec;
   let deviceRegistry;
   let instanceNaming;
@@ -31,9 +17,15 @@ describe('Genymotion-Cloud instances lookup service', () => {
     uut = new GenyInstancesLookupService(exec, instanceNaming, deviceRegistry);
   });
 
-  const aDisconnectedInstance = () => ({
-    ...anInstance(),
-    adb_serial: '0.0.0.0',
+  const anInstance = () => ({
+    uuid: 'mock-instance-uuid',
+    name: 'mock-instance-name',
+    adb_serial: 'mock-serial:1111',
+    state: 'ONLINE',
+    recipe: {
+      name: 'mock-recipe-name',
+      uuid: 'mock-recipe-uuid',
+    }
   });
   const anotherInstance = () => ({
     ...anInstance(),
@@ -47,90 +39,96 @@ describe('Genymotion-Cloud instances lookup service', () => {
     },
   });
 
-  const givenAllDevicesBusy = () => deviceRegistry.isDeviceBusy.mockReturnValue(true);
-  const givenAllDevicesFree = () => deviceRegistry.isDeviceBusy.mockReturnValue(false);
+  const givenRegisteredInstances = (...instances) => deviceRegistry.getRegisteredDevices.mockReturnValue([ ...instances.map((instance) => instance.uuid) ]);
+  const givenNoRegisteredInstances = () => deviceRegistry.getRegisteredDevices.mockReturnValue([]);
   const givenInstances = (...instances) => exec.getInstances.mockResolvedValue({ instances });
   const givenNoInstances = () => exec.getInstances.mockResolvedValue({ instances: [] });
   const givenAllDevicesFamilial = () => instanceNaming.isFamilial.mockReturnValue(true);
   const givenNoDevicesFamilial = () => instanceNaming.isFamilial.mockReturnValue(false);
 
-  it('should return null if there are no cloud-instances available', async () => {
-    givenNoInstances();
-    givenAllDevicesFree();
-    givenAllDevicesFamilial();
-    expect(await uut.findFreeInstance('mock-recipe-uuid')).toEqual(null);
-  });
-
-  it('should return a free instance', async () => {
-    const instance = anInstance();
-    givenInstances(instance);
-    givenAllDevicesFree();
-    givenAllDevicesFamilial();
-
-    const result = await uut.findFreeInstance(instance.recipe.uuid);
-    expect(result.uuid).toEqual(instance.uuid);
-    expect(result.constructor.name).toContain('Instance');
-  });
-
-  it('should not return an instance of a different recipe', async () => {
-    const instance = anInstance();
-    givenInstances(instance);
-    givenAllDevicesFree();
-    givenAllDevicesFamilial();
-
-    expect(await uut.findFreeInstance('different-recipe-uuid')).toEqual(null);
-  });
-
-  it('should not return an instance whose name isn\'t in the family', async () => {
-    const instance = anInstance();
-    givenInstances(instance);
-    givenAllDevicesFree();
-    givenNoDevicesFamilial();
-
-    expect(await uut.findFreeInstance(instance.recipe.uuid)).toEqual(null);
-    expect(instanceNaming.isFamilial).toHaveBeenCalledWith(instance.name);
-  });
-
-  it('should not return an instance marked "busy"', async () => {
-    const instance = anInstance();
-    givenInstances(instance);
-    givenAllDevicesBusy();
-    givenAllDevicesFamilial();
-
-    expect(await uut.findFreeInstance(instance.recipe.uuid)).toEqual(null);
-    expect(deviceRegistry.isDeviceBusy).toHaveBeenCalledWith(instance.adb_serial);
-  });
-
-  it('should not return a recycled (auto-shutdown) instance', async () => {
-    const instance = {
-      ...anInstance(),
-      state: INSTANCE_STATE_TERMINATED,
-    };
-    givenInstances(instance);
-    givenAllDevicesFree();
-    givenAllDevicesFamilial();
-
-    expect(await uut.findFreeInstance(instance.recipe.uuid)).toEqual(null);
-  });
-
-  it('should return a disconnected instance', async () => {
-    const instance = aDisconnectedInstance();
-    givenInstances(instance);
-    givenAllDevicesFree();
-    givenAllDevicesFamilial();
-
-    const result = await uut.findFreeInstance(instance.recipe.uuid);
-    expect(result.uuid).toEqual(instance.uuid);
-    expect(deviceRegistry.isDeviceBusy).not.toHaveBeenCalled();
+  describe('finding a free instance', () => {
+    it('should return null if there are no cloud-instances available', async () => {
+      givenNoInstances();
+      givenNoRegisteredInstances();
+      givenAllDevicesFamilial();
+      expect(await uut.findFreeInstance('mock-recipe-uuid')).toEqual(null);
+    });
+
+    it('should return a free online instance', async () => {
+      const instance = anInstance();
+      givenInstances(instance);
+      givenNoRegisteredInstances();
+      givenAllDevicesFamilial();
+
+      const result = await uut.findFreeInstance();
+      expect(result.uuid).toEqual(instance.uuid);
+      expect(result.constructor.name).toContain('Instance');
+    });
+
+    it('should not return an instance whose name isn\'t in the family', async () => {
+      const instance = anInstance();
+      givenInstances(instance);
+      givenNoRegisteredInstances();
+      givenNoDevicesFamilial();
+
+      expect(await uut.findFreeInstance()).toEqual(null);
+      expect(instanceNaming.isFamilial).toHaveBeenCalledWith(instance.name);
+    });
+
+    it('should not return an instance already taken by another worker', async () => {
+      const instance = anInstance();
+      givenInstances(instance);
+      givenRegisteredInstances(instance);
+      givenAllDevicesFamilial();
+
+      expect(await uut.findFreeInstance()).toEqual(null);
+    });
+
+    it('should not return an offline instance', async () => {
+      const instance = {
+        ...anInstance(),
+        state: 'OFFLINE',
+      };
+      givenInstances(instance);
+      givenNoRegisteredInstances();
+      givenAllDevicesFamilial();
+
+      expect(await uut.findFreeInstance()).toEqual(null);
+    });
+
+    it('should return a free initializing instance', async () => {
+      const instance = {
+        ...anInstance(),
+        state: 'BOOTING',
+      };
+      givenInstances(instance);
+      givenNoRegisteredInstances();
+      givenAllDevicesFamilial();
+
+      const result = await uut.findFreeInstance();
+      expect(result.uuid).toEqual(instance.uuid);
+      expect(result.constructor.name).toContain('Instance');
+    });
+
+    it('should filter multiple matches of multiple instances', async () => {
+      const instance = anInstance();
+      givenInstances(anInstanceOfOtherRecipe(), instance, anotherInstance());
+      givenNoRegisteredInstances();
+      givenAllDevicesFamilial();
+
+      const result = await uut.findFreeInstance();
+      expect(result.uuid).toEqual(instance.uuid);
+    });
   });
 
-  it('should filter multiple matches of multiple instances', async () => {
-    const instance = anInstance();
-    givenInstances(anInstanceOfOtherRecipe(), instance, anotherInstance());
-    givenAllDevicesFree();
-    givenAllDevicesFamilial();
+  describe('finding a specific instance', () => {
+    it('should return an instance matching a UUID', async () => {
+      const instance1 = anInstance();
+      const instance2 = anotherInstance();
+      givenInstances(instance1, instance2);
 
-    const result = await uut.findFreeInstance(instance.recipe.uuid);
-    expect(result.uuid).toEqual(instance.uuid);
+      const result = await uut.getInstance(instance2.uuid);
+      expect(result.uuid).toEqual(instance2.uuid);
+    });
   });
 });
diff --git a/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.test.js b/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.test.js
index eea6f6353..137ed42c4 100644
--- a/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.test.js
+++ b/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.test.js
@@ -17,10 +17,19 @@ describe('Genymotion-Cloud Instance DTO', () => {
     adb_serial_port: 0,
   };
 
-  const recycledRawInstance = {
-    ...connectedRawInstance,
-    state: 'RECYCLED',
-  }
+  const onlineInstance = disconnectedRawInstance;
+  const bootingInstance = {
+    ...onlineInstance,
+    state: 'BOOTING',
+  };
+  const startingInstance = {
+    ...onlineInstance,
+    state: 'STARTING',
+  };
+  const creatingInstance = {
+    ...onlineInstance,
+    state: 'CREATING',
+  };
 
   let Instance;
   beforeEach(() => {
@@ -50,13 +59,33 @@ describe('Genymotion-Cloud Instance DTO', () => {
     expect(instance.isAdbConnected()).toEqual(false);
   });
 
-  it('should indicate termination', () => {
-    const instance = new Instance(recycledRawInstance);
-    expect(instance.isTerminated()).toEqual(true);
+  it('should indicate instance is online', () => {
+    const instance = new Instance(onlineInstance);
+    expect(instance.isOnline()).toEqual(true);
   });
 
-  it('should indicate termination', () => {
-    const instance = new Instance(disconnectedRawInstance);
-    expect(instance.isTerminated()).toEqual(false);
+  it('should indicate instance in not online-state', () => {
+    const instance = new Instance(bootingInstance);
+    expect(instance.isOnline()).toEqual(false);
+  });
+
+  it('should indicate an instance that is under creation is initializing', () => {
+    const instance = new Instance(creatingInstance);
+    expect(instance.isInitializing()).toEqual(true);
+  });
+
+  it('should indicate an online instance is not initializing', () => {
+    const instance = new Instance(onlineInstance);
+    expect(instance.isInitializing()).toEqual(false);
+  });
+
+  it('should indicate a booting instance is initializing', () => {
+    const instance = new Instance(bootingInstance);
+    expect(instance.isInitializing()).toEqual(true);
+  });
+
+  it('should indicate a starting-up instance is initializing', () => {
+    const instance = new Instance(startingInstance);
+    expect(instance.isInitializing()).toEqual(true);
   });
 });
diff --git a/detox/src/devices/drivers/android/tools/FreeDeviceFinder.test.js b/detox/src/devices/drivers/android/tools/FreeDeviceFinder.test.js
index adc42eb06..2c38983dd 100644
--- a/detox/src/devices/drivers/android/tools/FreeDeviceFinder.test.js
+++ b/detox/src/devices/drivers/android/tools/FreeDeviceFinder.test.js
@@ -9,12 +9,12 @@ describe('FreeDeviceFinder', () => {
   beforeEach(() => {
     const DeviceRegistry = jest.genMockFromModule('../../../DeviceRegistry');
     mockDeviceRegistry = new DeviceRegistry();
-    mockDeviceRegistry.isDeviceBusy.mockReturnValue(false);
+    mockDeviceRegistry.includes.mockReturnValue(false);
 
     uut = new FreeDeviceFinder(mockAdb, mockDeviceRegistry);
   });
 
-  it('should return the only device when it matches, is online and not busy', async () => {
+  it('should return the only device when it matches, is online and not already taken by other workers', async () => {
     mockAdbDevices([emulator5556]);
 
     const result = await uut.findFreeDevice(emulator5556.adbName);
@@ -28,9 +28,9 @@ describe('FreeDeviceFinder', () => {
     expect(result).toEqual(null);
   });
 
-  it('should return null when device is busy', async () => {
+  it('should return null when device is already taken by other workers', async () => {
     mockAdbDevices([emulator5556]);
-    mockAllDevicesBusy();
+    mockAllDevicesTaken();
 
     expect(await uut.findFreeDevice(emulator5556.adbName)).toEqual(null);
   });
@@ -47,5 +47,5 @@ describe('FreeDeviceFinder', () => {
   });
 
   const mockAdbDevices = (devices) => mockAdb.devices.mockResolvedValue({ devices });
-  const mockAllDevicesBusy = () => mockDeviceRegistry.isDeviceBusy.mockReturnValue(true);
+  const mockAllDevicesTaken = () => mockDeviceRegistry.includes.mockReturnValue(true);
 });
diff --git a/detox/src/devices/drivers/ios/SimulatorDriver.test.js b/detox/src/devices/drivers/ios/SimulatorDriver.test.js
index 145f15622..71570132d 100644
--- a/detox/src/devices/drivers/ios/SimulatorDriver.test.js
+++ b/detox/src/devices/drivers/ios/SimulatorDriver.test.js
@@ -123,7 +123,8 @@ describe('IOS simulator driver', () => {
     beforeEach(() => {
       const SimulatorDriver = require('./SimulatorDriver');
       uut = new SimulatorDriver({ client: {}, emitter });
-      jest.spyOn(uut.deviceRegistry, 'isDeviceBusy').mockReturnValue(false);
+      jest.spyOn(uut.deviceRegistry, 'includes').mockReturnValue(false);
+      jest.spyOn(uut.deviceRegistry, 'getRegisteredDevices').mockReturnValue([]);
       applesimutils = uut.applesimutils;
       applesimutils.list.mockImplementation(async () => require('./tools/applesimutils.mock')['--list']);
     });
diff --git a/detox/test/package.json b/detox/test/package.json
index f420ad078..74d2d83bf 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-test",
-  "version": "17.11.4",
+  "version": "17.12.0",
   "private": true,
   "engines": {
     "node": ">=8.3.0"
@@ -31,7 +31,7 @@
   },
   "devDependencies": {
     "@babel/core": "^7.8.7",
-    "detox": "^17.11.4",
+    "detox": "^17.12.0",
     "express": "^4.15.3",
     "jest": "^26.5.0",
     "jest-circus": "^26.5.2",
@@ -136,7 +136,7 @@
         "build": "cd android && ./gradlew assembleFromBinRelease assembleFromBinReleaseAndroidTest -DtestBuildType=release && cd ..",
         "type": "android.genycloud",
         "device": {
-          "recipeName": "Detox_Pixel_API_29"
+          "recipeUUID": "a50a71d6-da90-4c67-bdfa-5b602b0bbd15"
         }
       },
       "android.genycloud.release2": {
@@ -144,7 +144,7 @@
         "build": "cd android && ./gradlew assembleFromBinRelease assembleFromBinReleaseAndroidTest -DtestBuildType=release && cd ..",
         "type": "android.genycloud",
         "device": {
-          "recipeUUID": "a9da8503-c1d0-4aa0-9d8d-8b551da877d3"
+          "recipeName": "Detox_Pixel_API_29"
         }
       },
       "android.emu.debug.fromSource": {