Repository: wix/Detox
Pull request #2399 (2020-10-09 15:38:12)

Configurable launchArgs
**Description:**

This pull request adds two things.

### detox.configurations[conf].launchArgs

```diff
 "ios.sim.release": {
   "type": "ios.simulator",
   "binaryPath": "ios/build/Build/Products/Release-iphonesimulator/example.app"
+  "launchArgs": {
+    "detoxPrintBusyIdleResources": "YES"
+  },
   "device": {
     "type": "iPhone 11 Pro"
   },
 },
```

So that you can add some default `launchArgs` if your app configuration needs them all the time.

### DETOX_APP_LAUNCH_ARGS

E.g., you need to see what's wrong with the busy idle resources, so instead of going somewhere to patch your configuration, you just can re-run locally on your machine, when troubleshooting:

```bash
DETOX_APP_LAUNCH_ARGS="-detoxDebugVisibility YES" detox test -c ios.sim.release
```

and this extra option just automatically jumps in, along with the other ones.

### Conclusion

What do you think, guys?

LeoNatan: I don’t understand why detoxDebugVisibility should be enabled in CI?
noomorph: @LeoNatan, to debug cases when the visibility check fails only on CI. AFAIR, there was one like that already.
LeoNatan: How will you collect from CI’s Deksyop?
noomorph: Well, it might become possible in `globalTeardown` phase, if we eventually build our internal Detox preset for Jest.

Plus, the purpose was not limited to CI.
I just want to be able to re-run Detox locally with extra launch args (usually, yes, for troubleshooting) but without rummaging where do they launch their app in the project.
noomorph: @LeoNatan, I've edited my example according to your request.
LeoNatan: Some care needs to be put with future expansions of Detox, for example supporting multiple apps. 

Why is this necessary as an environment variable, compared to making a branch with changed arguments in init file?
noomorph: @LeoNatan, the current Detox implementation fails miserably at handling multiple applications at once, so until a big rewrite in JS part, I don't think it is a subject for the discussion at the moment – and the said rewrite will 100% result into breaking changes for the public API.

And if you think that launchArgs break many-apps concept, then why does not having "binaryPath" alongside conflict with the concept? It is also a dealbreaker for many-to-many relations between apps and devices.

As for why, I'll come from another side – we already have for a long time those `--device-launch-args ""` CLI support, so why can't we have both, for customizing app launches, for the balance?

Of course, while making a branch is always an option, for example, it steals my time when debugging 3-4 projects in one day, and also it complicates life for people who submit new bug reports to Detox Github Issues. I mean, when we request people to send more logs and artifacts, it is a bit problematic to explain how in one step they can extract all the necessary evidence after they run. Telling:

> Please re-run your Detox with `DETOX_APP_LAUNCH_ARGS="-detoxPrintBusyIdleResources YES" detox test -c <your-config> --loglevel trace" and submit your report here.

sounds easier for me than explaining to go somewhere and change their app launch args, especially given that with the current Detox + Jest Circus implementation the app launch happens implicitly, within `CustomDetoxEnvironment`, unless you set `behavior.launchApp = false` and add your custom `beforeAll(() => device.launchApp({ ...options }))` step.
LeoNatan: Path is an old issue which has been discussed in the past how to resolve. But you are suggesting let's pile on more stuff just because some old ones exist. 

At some point feature creep is a Bad Thing™, and it doesn't sound to me like a compelling feature. 🤷‍♂️

The entire auto launch is also a Bad Thing™, one that we discussed with @rotemmiz to remove from init() process. App launching should be entirely the responsibility of the test. 
noomorph: > Path is an old issue which has been discussed in the past how to resolve. But you are suggesting let's pile on more stuff just because some old ones exist.

Yes, let's pile, since anyway that fits the unfortunate existing direction of the project circa 2017, which we haven't managed to drastically change, so instead of struggle with it, I opt to adopt it and leverage the most of it.

> At some point feature creep is a Bad Thing™, and it doesn't sound to me like a compelling feature. 🤷‍♂️

I don't feel like this particular feature is bloating the system and makes its design more complex.
The configuration workflow `JS/JSON (detoxrc) < environment variables < command line args` is ingrained now everywhere in Detox. Here I just have aligned the blank spot on the map to fit the general trend.

I can't say our official troubleshooting techniques are easy to follow – there are so many toggles and switches in the system (debug synchronization, loglevel, artifacts, advanced launch args), so it becomes not trivial for a newcomer to figure out how to make Detox spit the necessary debug info, as it is scattered and dependent on many factors.

And I don't try to fight this toggles-n-switches system at the moment, I am just trying to make sure all the handles are available for immediate use, first of all. If we are talking about a well-designed and easy-to-use system, then all Detox needs is to provide a single diagnostics mode (ON/OFF), which just works and gives you every detail you need, so you can relax and turn off your brain, but this is a feature we can't really afford in the scope of this month, I am afraid, yet still, there's a lot of pending bug fixing work.

And since we have not approached that holy grail, I am fixing issues within the current paradigm, even if that means to provide certain shortcuts for quicker troubleshooting.

> The entire auto launch is also a Bad Thing™, one that we discussed with @rotemmiz to remove from init() process. App launching should be entirely the responsibility of the test.

Here I totally agree, and I think we can implement that in the next Detox 18, if we all share this vision. 👍 
LeoNatan: If love to. I think one of the main issues in making this change is communicating it to the community. If we just stop launching apps, people will cry because virtually all tests will break. If we throw a warning now and stop launching in the future, that won’t change a thing because people are reliably lazy and don’t read logs, even when their tests are failing. 🤦‍♂️ This is why an initial design and implementation is very important, and just piling on features is a Bad Thing™, and we should pay concern to at least things we’ve though about, such as multiple apps. 
noomorph: I have no issue with releasing breaking changes when necessary.

The problem here is that although I've been working on Detox from 7.x up till now, 17.x, those Bad Things™ remained intact in spite of many other things changing. I just don't believe in the multiple-apps feature without a prior heavy internal and external redesign.

Looking at our priorities distribution (perf project, internal preset) I am very doubtful we'll arrive at the feature you mention during this 2020. 😑 I hope I am wrong.
rotemmiz: I like this feature, but we need to make sure it actually solves the problem not only with static launch args. I think it makes a lot of sense, especially when the config is a js file that can dynamically resolve launch args (mock server ports, etc.), though I am not sure it solves it all.

The main usecase we see internally is wrapping `device.launchApp()` just to add in all the required launch args in one place, this of course, solves that, especially with the example you showed.
I suggest you check how dynamic resolution works during global setup and during suite setup, so we'll have good understanding of setting up dynamic launch args through `.dtxrc.js`


rotemmiz: Regarding @LeoNatan's concern, well, although I agree with you, I think that whatever we throw into `configuration` is really only for one application.
If and when we'd want to support multiple apps in we'd need multiple instances, that can be represented as an array/map inside a single configuration, each should probably take instance should take its configuration from a different config in that array/map.
I agree this is not an elaborate design, and we might come up with a totally different way here, but I still think the suggested implementation fits.
d4vidi: Guys I'm sorry -- 'didn't go over the whole thread here. However, as a bottom line I agree this feature adds value and I'm for it. 'Only concern I have is exactly as @rotemmiz has described it: dynamic key/value resolution. I've found myself already, in the past, at situations where I was swizzling `device.launchApp()` for projects, incorporating dynamic resolution logic. I suppose a `.dtxrc.js` would do the trick here, albeit being a bit implicit to my taste. Perhaps a more elegant way is around the corner, but it shouldn't block moving forward here.

Patch:
diff --git a/detox/android/detox/src/main/java/com/wix/detox/reactnative/ReactNativeExtension.kt b/detox/android/detox/src/main/java/com/wix/detox/reactnative/ReactNativeExtension.kt
index f86b783e0..794700b63 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/reactnative/ReactNativeExtension.kt
+++ b/detox/android/detox/src/main/java/com/wix/detox/reactnative/ReactNativeExtension.kt
@@ -99,13 +99,6 @@ object ReactNativeExtension {
         }
     }
 
-    @JvmStatic
-    fun toggleJSBridgeSynchronization(enable: Boolean) {
-        rnIdlingResources?.let {
-            if (enable) it.resumeJSBridgeIdlingResource() else it.pauseJSBridgeIdlingResource()
-        }
-    }
-
     private fun reloadReactNativeInBackground(reactApplication: ReactApplication) {
         val rnReloader = ReactNativeReLoader(InstrumentationRegistry.getInstrumentation(), reactApplication)
         rnReloader.reloadInBackground()
diff --git a/detox/ios/DTXLoggingInfra b/detox/ios/DTXLoggingInfra
index 47261f5d7..60bbc2a6a 160000
--- a/detox/ios/DTXLoggingInfra
+++ b/detox/ios/DTXLoggingInfra
@@ -1,1 +1,1 @@
-Subproject commit 47261f5d7ec932e9ef8d1829707af7069c40192e
+Subproject commit 60bbc2a6abb39ec222d8926ba11e0e69cc7c98b3
diff --git a/detox/ios/Detox.xcodeproj/project.pbxproj b/detox/ios/Detox.xcodeproj/project.pbxproj
index 97557f111..598eb809b 100644
--- a/detox/ios/Detox.xcodeproj/project.pbxproj
+++ b/detox/ios/Detox.xcodeproj/project.pbxproj
@@ -813,7 +813,7 @@
 			isa = PBXProject;
 			attributes = {
 				LastSwiftUpdateCheck = 0830;
-				LastUpgradeCheck = 1200;
+				LastUpgradeCheck = 1220;
 				ORGANIZATIONNAME = Wix;
 				TargetAttributes = {
 					3928EFA41E47404900C19B6E = {
@@ -834,6 +834,7 @@
 			hasScannedForEncodings = 0;
 			knownRegions = (
 				en,
+				Base,
 			);
 			mainGroup = 3947678D1DBF985400D72256;
 			productRefGroup = 394767981DBF985400D72256 /* Products */;
diff --git a/detox/ios/Detox.xcodeproj/xcshareddata/xcschemes/Detox.xcscheme b/detox/ios/Detox.xcodeproj/xcshareddata/xcschemes/Detox.xcscheme
index 4dce3ac65..b752693a9 100644
--- a/detox/ios/Detox.xcodeproj/xcshareddata/xcschemes/Detox.xcscheme
+++ b/detox/ios/Detox.xcodeproj/xcshareddata/xcschemes/Detox.xcscheme
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
-   LastUpgradeVersion = "1160"
+   LastUpgradeVersion = "1220"
    version = "1.3">
    <BuildAction
       parallelizeBuildables = "YES"
diff --git a/detox/ios/Detox/Actions/UIScrollView+DetoxActions.h b/detox/ios/Detox/Actions/UIScrollView+DetoxActions.h
index e58dd4b53..ba9a87774 100644
--- a/detox/ios/Detox/Actions/UIScrollView+DetoxActions.h
+++ b/detox/ios/Detox/Actions/UIScrollView+DetoxActions.h
@@ -12,7 +12,7 @@ NS_ASSUME_NONNULL_BEGIN
 
 @interface UIScrollView (DetoxActions)
 
-- (void)dtx_scrollToNormalizedEdge:(CGPoint)edge;
+- (void)dtx_scrollToEdge:(UIRectEdge)edge;
 - (void)dtx_scrollWithOffset:(CGPoint)offset;
 - (void)dtx_scrollWithOffset:(CGPoint)offset normalizedStartingPoint:(CGPoint)normalizedStartingPoint NS_SWIFT_NAME(dtx_scroll(withOffset:normalizedStartingPoint:));
 
diff --git a/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m b/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m
index c204eddc5..b89ade768 100644
--- a/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m
+++ b/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m
@@ -10,6 +10,8 @@
 #import "UIApplication+DTXAdditions.h"
 #import "DTXSyntheticEvents.h"
 #import "UIView+DetoxUtils.h"
+#import "DTXAppleInternals.h"
+#import "DetoxPolicy.h"
 
 @import ObjectiveC;
 
@@ -78,49 +80,45 @@ else \
 { \
 	target = self.contentSize._size + self.adjustedContentInset.otherInset - self.bounds.size._size; \
 } \
-CGFloat delta = self.contentOffset.axis - target; \
-CGPoint offset = pointMakeMacro(delta); \
-if(offset.axis != 0.0 && [self _dtx_canScrollWithOffset:offset] == NO) \
-{\
-	/* Already at edge */ \
-	return; \
-}\
-[self dtx_scrollWithOffset:offset];
+[self setContentOffset:pointMakeMacro(target) animated:YES]; \
+[NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:[[self valueForKeyPath:@"animation.duration"] doubleValue] + 0.05]];
 
-- (void)dtx_scrollToNormalizedEdge:(CGPoint)edge
+- (void)dtx_scrollToEdge:(UIRectEdge)edge
 {
-	if(edge.x != 0)
-	{
-		DTX_SCROLL_TO_EDGE(x, left, right, width, DTXCGMakePointX);
-	}
-	else if(edge.y != 0)
-	{
-		DTX_SCROLL_TO_EDGE(y, top, bottom, height, DTXCGMakePointY);
+	CGPoint normalizedEdge;
+	switch (edge) {
+		case UIRectEdgeTop:
+			normalizedEdge = CGPointMake(0, -1);
+			break;
+		case UIRectEdgeBottom:
+			normalizedEdge = CGPointMake(0, 1);
+			break;
+		case UIRectEdgeLeft:
+			normalizedEdge = CGPointMake(-1, 0);
+			break;
+		case UIRectEdgeRight:
+			normalizedEdge = CGPointMake(1, 0);
+			break;
+		default:
+			DTXAssert(NO, @"Incorect edge provided.");
+			return;
 	}
+	
+	[self _dtx_scrollToNormalizedEdge:normalizedEdge];
 }
 
-#define DTX_CAN_SCROLL_AXIS(main, inset, otherInset, _size) \
-if(offset.main > 0 && floor(self.contentOffset.main) <= - floor(self.adjustedContentInset.inset)) \
-{ \
-	return NO; \
-} \
-else if(offset.main < 0 && floor(self.contentOffset.main) >= floor(self.contentSize._size + self.adjustedContentInset.otherInset - self.bounds.size._size)) \
-{ \
-	return NO; \
-}
-
-- (BOOL)_dtx_canScrollWithOffset:(CGPoint)offset
+- (void)_dtx_scrollToNormalizedEdge:(CGPoint)edge
 {
-	if(offset.x != 0)
-	{
-		DTX_CAN_SCROLL_AXIS(x, left, right, width);
-	}
-	else if(offset.y != 0)
-	{
-		DTX_CAN_SCROLL_AXIS(y, top, bottom, height);
-	}
+//	if(edge.x != 0)
+//	{
+//		DTX_SCROLL_TO_EDGE(x, left, right, width, DTXCGMakePointX);
+//	}
+//	else if(edge.y != 0)
+//	{
+//		DTX_SCROLL_TO_EDGE(y, top, bottom, height, DTXCGMakePointY);
+//	}
 	
-	return YES;
+	[self _dtx_scrollWithOffset:CGPointMake(- edge.x * CGFLOAT_MAX, - edge.y * CGFLOAT_MAX) normalizedStartingPoint:CGPointMake(NAN, NAN) strict:NO];
 }
 
 DTX_ALWAYS_INLINE
@@ -132,14 +130,9 @@ static NSString* _DTXScrollDirectionDescriptionWithOffset(CGPoint offset)
 	return offset.x < 0 ? @"right" : offset.x > 0 ? @"left" : offset.y < 0 ? @"down" : @"up";
 }
 
-- (void)_dtx_assertCanScrollWithOffset:(CGPoint)offset
-{
-	DTXViewAssert([self _dtx_canScrollWithOffset:offset], self.dtx_viewDebugAttributes, @"Unable to scroll %@ in “%@”", _DTXScrollDirectionDescriptionWithOffset(offset), self.dtx_shortDescription);
-}
-
 - (void)dtx_scrollWithOffset:(CGPoint)offset
 {
-	[self dtx_scrollWithOffset:offset normalizedStartingPoint:CGPointMake(NAN, NAN)];
+	[self _dtx_scrollWithOffset:offset normalizedStartingPoint:CGPointMake(NAN, NAN) strict:YES];
 }
 
 #define DTX_CREATE_SCROLL_POINTS(window, points, startPoint, offset, main, windowSafeAreaMain, windowMoundsMain) \
@@ -153,14 +146,8 @@ while (offset.main != 0 && startPoint.main > window.safeAreaInsets.windowSafeAre
 }
 
 DTX_ALWAYS_INLINE
-static void _DTXApplyScroll(UIScrollView* scrollView, CGPoint startPoint, CGPoint offset, CGPoint* remainingOffset)
+static BOOL _DTXApplyScroll(UIScrollView* scrollView, CGPoint startPoint, CGPoint offset, CGPoint* remainingOffset)
 {
-	if([scrollView _dtx_canScrollWithOffset:offset] == NO)
-	{
-		*remainingOffset = CGPointZero;
-		return;
-	}
-	
 	NSMutableArray<NSValue*>* points = [NSMutableArray new];
 	
 	[points addObject:@(startPoint)];
@@ -205,12 +192,63 @@ static void _DTXApplyScroll(UIScrollView* scrollView, CGPoint startPoint, CGPoin
 	
 	if(points.count > 1)
 	{
-		[DTXSyntheticEvents touchAlongPath:points relativeToWindow:scrollView.window holdDurationOnLastTouch:0.0];
+		__block NSUInteger consecutiveTouchPointsWithSameContentOffset = 0;
+		__block BOOL didSomeScroll = NO;
+		__block CGPoint prevOffset = scrollView.contentOffset;
+		
+		//10 points for UIPanGestureRecognizer to recognizer the pan, plus policy (2) for leeway.
+		const NSUInteger consecutiveTouchPointsWithSameContentOffsetThreshold = (10 + DetoxPolicy.activePolicy.consecutiveTouchPointsWithSameContentOffsetThreshold);
+		
+		__block BOOL didFailTouches = NO;
+		[DTXSyntheticEvents touchAlongPath:points relativeToWindow:scrollView.window holdDurationOnLastTouch:0.0 onTouchCallback:^ BOOL (UITouchPhase phase) {
+			if(phase != UITouchPhaseMoved)
+			{
+				return YES;
+			}
+			
+			if(CGPointEqualToPoint(scrollView.contentOffset, prevOffset))
+			{
+				consecutiveTouchPointsWithSameContentOffset++;
+			}
+			else if(scrollView._isBouncing == NO)
+			{
+				didSomeScroll |= YES;
+			}
+			
+			prevOffset = scrollView.contentOffset;
+			
+			if(scrollView.bounces && scrollView._isBouncing)
+			{
+				if(didSomeScroll == NO)
+				{
+					didFailTouches = YES;
+				}
+				return NO;
+			}
+			
+			if(consecutiveTouchPointsWithSameContentOffset > consecutiveTouchPointsWithSameContentOffsetThreshold)
+			{
+				if(didSomeScroll == NO)
+				{
+					didFailTouches = YES;
+				}
+				return NO;
+			}
+			
+			return YES;
+		}];
 		
-		[NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.7]];
+		[NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.75]];
+		
+		if(didFailTouches)
+		{
+			return NO;
+		}
 	}
 	
 	*remainingOffset = offset;
+	
+	return YES;
 }
 
 #define DTX_RESET_START_POINT(normalizedStartingPoint, main, other, offset) \
@@ -224,6 +262,11 @@ if(isnan(normalizedStartingPoint.main) || normalizedStartingPoint.main < 0 || no
 }
 
 - (void)dtx_scrollWithOffset:(CGPoint)offset normalizedStartingPoint:(CGPoint)normalizedStartingPoint
+{
+	[self _dtx_scrollWithOffset:offset normalizedStartingPoint:normalizedStartingPoint strict:YES];
+}
+
+- (void)_dtx_scrollWithOffset:(CGPoint)offset normalizedStartingPoint:(CGPoint)normalizedStartingPoint strict:(BOOL)strict
 {
 	if(offset.x == 0.0 && offset.y == 0.0)
 	{
@@ -231,11 +274,12 @@ if(isnan(normalizedStartingPoint.main) || normalizedStartingPoint.main < 0 || no
 	}
 	
 	NSAssert(offset.x == 0.0 || offset.y == 0.0, @"Scrolling simultaneously in both directions is unsupported");
-	[self _dtx_assertCanScrollWithOffset:offset];
 	
 	self.dtx_disableDecelerationForScroll = YES;
+//	BOOL oldBounces = self.bounces;
+//	self.bounces = NO;
 	
-	CGRect safeAreaToScroll = UIEdgeInsetsInsetRect(self.bounds, self.adjustedContentInset);
+	CGRect safeAreaToScroll = [self.window convertRect:UIEdgeInsetsInsetRect(self.bounds, self.adjustedContentInset) fromView:self];
 	
 	if(offset.y != 0)
 	{
@@ -248,16 +292,22 @@ if(isnan(normalizedStartingPoint.main) || normalizedStartingPoint.main < 0 || no
 	
 	CGPoint startPoint = CGPointMake(safeAreaToScroll.origin.x + safeAreaToScroll.size.width * normalizedStartingPoint.x, safeAreaToScroll.origin.y + safeAreaToScroll.size.height * normalizedStartingPoint.y);
 	
-//	[self dtx_assertHittableAtPoint:startPoint];
-	
-	startPoint = [self.window convertPoint:startPoint fromView:self];
-	
+	NSUInteger successfullyAppliedScrolls = 0;
 	while (offset.x != 0.0 || offset.y != 0.0)
 	{
-		_DTXApplyScroll(self, startPoint, offset, &offset);
+		BOOL appliedScroll = _DTXApplyScroll(self, startPoint, offset, &offset);
+		successfullyAppliedScrolls += (appliedScroll ? 1 : 0);
+		
+		if(appliedScroll == NO)
+		{
+			break;
+		}
 	}
 	
+	DTXViewAssert(strict == NO || successfullyAppliedScrolls > 0, self.dtx_viewDebugAttributes, @"Unable to scroll %@ in “%@”", _DTXScrollDirectionDescriptionWithOffset(offset), self.dtx_shortDescription);
+	
 	self.dtx_disableDecelerationForScroll = NO;
+//	self.bounces = oldBounces;
 }
 
 @end
diff --git a/detox/ios/Detox/AppleInternals/DTXAppleInternals.h b/detox/ios/Detox/AppleInternals/DTXAppleInternals.h
index 72ac372f0..84e996b00 100644
--- a/detox/ios/Detox/AppleInternals/DTXAppleInternals.h
+++ b/detox/ios/Detox/AppleInternals/DTXAppleInternals.h
@@ -49,7 +49,7 @@
 
 @end
 
-@interface UIWindow (GREYExposed)
+@interface UIWindow ()
 
 - (id)firstResponder;
 + (instancetype)keyWindow;
@@ -67,7 +67,7 @@
 
 @end
 
-@interface UIViewController (GREYExposed)
+@interface UIViewController ()
 - (void)viewWillMoveToWindow:(id)window;
 - (void)viewDidMoveToWindow:(id)window shouldAppearOrDisappear:(BOOL)arg;
 @end
@@ -197,7 +197,7 @@ IOHIDEventRef IOHIDEventCreateDigitizerFingerEvent(CFAllocatorRef allocator,
 - (void)_setSubtype:(int)eventSubType;
 @end
 
-@interface UIApplication (GREYExposed)
+@interface UIApplication ()
 - (BOOL)_isSpringBoardShowingAnAlert;
 - (UIWindow *)statusBarWindow;
 /**
@@ -240,7 +240,10 @@ IOHIDEventRef IOHIDEventCreateDigitizerFingerEvent(CFAllocatorRef allocator,
 - (void)_sendMotionEnded:(UIEventSubtype)subtype;
 @end
 
-@interface UIScrollView (GREYExposed)
+@interface UIScrollView ()
+
+@property (nonatomic, readonly) BOOL _isBouncing;
+
 /**
  *  Called when user finishes scrolling the content. @c deceleration is @c YES if scrolling movement
  *  will continue, but decelerate, after user stopped dragging the content. If @c deceleration is
@@ -265,11 +268,11 @@ IOHIDEventRef IOHIDEventCreateDigitizerFingerEvent(CFAllocatorRef allocator,
 - (void)_stopScrollDecelerationNotify:(BOOL)notify;
 @end
 
-@interface UIDevice (GREYExposed)
+@interface UIDevice ()
 - (void)setOrientation:(UIDeviceOrientation)orientation animated:(BOOL)animated;
 @end
 
-@interface UITouch (GREYExposed)
+@interface UITouch ()
 /**
  *  Sets flag marking this touch as the first touch for view.
  *
diff --git a/detox/ios/Detox/AppleTouchEvents/DTXSyntheticEvents.h b/detox/ios/Detox/AppleTouchEvents/DTXSyntheticEvents.h
index 3cc454352..24d4aefbb 100644
--- a/detox/ios/Detox/AppleTouchEvents/DTXSyntheticEvents.h
+++ b/detox/ios/Detox/AppleTouchEvents/DTXSyntheticEvents.h
@@ -33,8 +33,14 @@ typedef NS_ENUM(NSInteger, GREYSyntheticEventInjectionErrorCode) {
  */
 @interface DTXSyntheticEvents : NSObject
 
+- (instancetype)init NS_UNAVAILABLE;
++ (instancetype)new NS_UNAVAILABLE;
+
 + (void)touchAlongPath:(NSArray *)touchPath relativeToWindow:(UIWindow *)window holdDurationOnLastTouch:(NSTimeInterval)holdDuration;
++ (void)touchAlongPath:(NSArray *)touchPath relativeToWindow:(UIWindow *)window holdDurationOnLastTouch:(NSTimeInterval)holdDuration onTouchCallback:(nullable BOOL (^)(UITouchPhase))callback;
+
 + (void)touchAlongMultiplePaths:(NSArray *)touchPaths relativeToWindow:(UIWindow *)window holdDurationOnLastTouch:(NSTimeInterval)holdDuration;
++ (void)touchAlongMultiplePaths:(NSArray *)touchPaths relativeToWindow:(UIWindow *)window holdDurationOnLastTouch:(NSTimeInterval)holdDuration onTouchCallback:(nullable BOOL (^)(UITouchPhase))callback;
 
 /**
  *  Begins interaction with a new touch starting at a specified point within a specified
diff --git a/detox/ios/Detox/AppleTouchEvents/DTXSyntheticEvents.m b/detox/ios/Detox/AppleTouchEvents/DTXSyntheticEvents.m
index 0e2b747a3..a3760e0d5 100644
--- a/detox/ios/Detox/AppleTouchEvents/DTXSyntheticEvents.m
+++ b/detox/ios/Detox/AppleTouchEvents/DTXSyntheticEvents.m
@@ -21,7 +21,8 @@
 
 #pragma mark - Implementation
 
-@implementation DTXSyntheticEvents {
+@implementation DTXSyntheticEvents
+{
 	/**
 	 *  The touch injector that completes the touch sequence for an event.
 	 */
@@ -31,20 +32,43 @@
 	 *  The last injected touch point.
 	 */
 	NSValue *_lastInjectedTouchPoint;
+	
+	BOOL (^_onTouchCallback)(UITouchPhase);
+}
+
+- (instancetype)initWithOnTouchCallback:(nullable BOOL (^)(UITouchPhase))onTouchCallback
+{
+	self = [super init];
+	if(self)
+	{
+		_onTouchCallback = onTouchCallback;
+	}
+	
+	return self;
 }
 
 + (void)touchAlongPath:(NSArray *)touchPath relativeToWindow:(UIWindow *)window holdDurationOnLastTouch:(NSTimeInterval)holdDuration
 {
-	[self touchAlongMultiplePaths:@[touchPath] relativeToWindow:window holdDurationOnLastTouch:holdDuration];
+	[self touchAlongMultiplePaths:@[touchPath] relativeToWindow:window holdDurationOnLastTouch:holdDuration onTouchCallback:nil];
+}
+
++ (void)touchAlongPath:(NSArray *)touchPath relativeToWindow:(UIWindow *)window holdDurationOnLastTouch:(NSTimeInterval)holdDuration onTouchCallback:(BOOL (^)(UITouchPhase))callback
+{
+	[self touchAlongMultiplePaths:@[touchPath] relativeToWindow:window holdDurationOnLastTouch:holdDuration onTouchCallback:callback];
 }
 
 + (void)touchAlongMultiplePaths:(NSArray *)touchPaths relativeToWindow:(UIWindow *)window holdDurationOnLastTouch:(NSTimeInterval)holdDuration
+{
+	[self touchAlongMultiplePaths:touchPaths relativeToWindow:window holdDurationOnLastTouch:holdDuration onTouchCallback:nil];
+}
+
++ (void)touchAlongMultiplePaths:(NSArray *)touchPaths relativeToWindow:(UIWindow *)window holdDurationOnLastTouch:(NSTimeInterval)holdDuration onTouchCallback:(BOOL (^)(UITouchPhase))callback
 {
 	NSParameterAssert(touchPaths.count >= 1);
 	NSParameterAssert(holdDuration >= 0);
 	
 	NSUInteger firstTouchPathSize = [touchPaths[0] count];
-	DTXSyntheticEvents *eventGenerator = [DTXSyntheticEvents new];
+	DTXSyntheticEvents *eventGenerator = [[DTXSyntheticEvents alloc] initWithOnTouchCallback:callback];
 	
 	// Inject "begin" event for the first points of each path.
 	[eventGenerator dtx_beginTouchesAtPoints:[self dtx_objectsAtIndex:0 ofArrays:touchPaths] relativeToWindow:window immediateDelivery:NO];
@@ -120,7 +144,7 @@
 - (void)dtx_beginTouchesAtPoints:(NSArray *)points relativeToWindow:(UIWindow *)window immediateDelivery:(BOOL)immediate
 {
 	NSAssert(_touchInjector == nil, @"Cannot call this method more than once until endTouch is called.");
-	_touchInjector = [[DTXTouchInjector alloc] initWithWindow:window];
+	_touchInjector = [[DTXTouchInjector alloc] initWithWindow:window onTouchInectCallback:_onTouchCallback];
 	DTXTouchInfo *touchInfo = [[DTXTouchInfo alloc] initWithPoints:points phase:DTXTouchInfoPhaseTouchBegan deliveryTimeDeltaSinceLastTouch:0 expendable:NO];
 	[_touchInjector enqueueTouchInfoForDelivery:touchInfo];
 	
diff --git a/detox/ios/Detox/AppleTouchEvents/DTXTouchInjector.h b/detox/ios/Detox/AppleTouchEvents/DTXTouchInjector.h
index 60bcc7388..42c0753a1 100644
--- a/detox/ios/Detox/AppleTouchEvents/DTXTouchInjector.h
+++ b/detox/ios/Detox/AppleTouchEvents/DTXTouchInjector.h
@@ -54,11 +54,12 @@ NS_ASSUME_NONNULL_BEGIN
  *  Initializes with the @c window to which touches will be delivered.
  *
  *  @param window The window that receives the touches.
+ *  @param callback An optional callback to be invoked upon every touch injection.
  *
  *  @return An instance of GREYSingleSequenceTouchInjector, initialized with the window to be
  *          touched.
  */
-- (instancetype)initWithWindow:(UIWindow *)window NS_DESIGNATED_INITIALIZER;
+- (instancetype)initWithWindow:(UIWindow *)window onTouchInectCallback:(nullable BOOL(^)(UITouchPhase))callback NS_DESIGNATED_INITIALIZER;
 
 /**
  *  Enqueues @c touchInfo that will be materialized into a UITouch and delivered to application.
diff --git a/detox/ios/Detox/AppleTouchEvents/DTXTouchInjector.m b/detox/ios/Detox/AppleTouchEvents/DTXTouchInjector.m
index 62a0dc9b5..7bd60ef5c 100644
--- a/detox/ios/Detox/AppleTouchEvents/DTXTouchInjector.m
+++ b/detox/ios/Detox/AppleTouchEvents/DTXTouchInjector.m
@@ -42,9 +42,12 @@
 	// whether an injected touch needs to be stationary or not.
 	// May be nil.
 	DTXTouchInfo* _previousTouchInfo;
+	
+	BOOL (^_callback)(UITouchPhase);
+	BOOL _abortedByCallback;
 }
 
-- (instancetype)initWithWindow:(UIWindow *)window
+- (instancetype)initWithWindow:(UIWindow *)window onTouchInectCallback:(nullable BOOL(^)(UITouchPhase))callback
 {
 	NSParameterAssert(window != nil);
 	
@@ -55,6 +58,7 @@
 		_enqueuedTouchInfoList = [[NSMutableArray alloc] init];
 		_state = kDTXTouchInjectorPendingStart;
 		_ongoingUITouches = [[NSMutableArray alloc] init];
+		_callback = callback;
 	}
 	return self;
 }
@@ -128,19 +132,39 @@
 		}
 		return;
 	}
+	
+	UITouchPhase reportedPhase;
+	
 	if ([_ongoingUITouches count] == 0)
 	{
+		reportedPhase = UITouchPhaseBegan;
 		[self dtx_extractAndChangeTouchToStartPhase:touchInfo];
 	}
 	else if (touchInfo.phase == DTXTouchInfoPhaseTouchEnded)
 	{
+		reportedPhase = UITouchPhaseEnded;
 		[self dtx_changeTouchToEndPhase:touchInfo];
 	}
 	else
 	{
-		[self dtx_changeTouchToMovePhase:touchInfo];
+		reportedPhase = UITouchPhaseMoved;
+		if(_abortedByCallback == NO)
+		{
+			[self dtx_changeTouchToMovePhase:touchInfo];
+		}
 	}
 	[self dtx_injectTouches:touchInfo];
+	
+	if(_callback && _abortedByCallback == NO)
+	{
+		_abortedByCallback = _callback(reportedPhase) == NO;
+		
+		if(_abortedByCallback)
+		{
+			//Clean up the pending touches list from any subsequent non -Ended touches.
+			[_enqueuedTouchInfoList filterUsingPredicate:[NSPredicate predicateWithFormat:@"phase == %@", @(DTXTouchInfoPhaseTouchEnded)]];
+		}
+	}
 }
 
 
diff --git a/detox/ios/Detox/Invocation/Action.swift b/detox/ios/Detox/Invocation/Action.swift
index 0742d7cad..13cf50303 100644
--- a/detox/ios/Detox/Invocation/Action.swift
+++ b/detox/ios/Detox/Invocation/Action.swift
@@ -269,21 +269,21 @@ class ScrollAction : Action {
 	}
 	
 	override func perform(on element: Element, completionHandler: @escaping ([String: Any]?, Error?) -> Void) {
-		let pixels = params![0] as! Double
+		let points = params![0] as! Double
 		let directionString = params![1] as! String
 		let targetOffset : CGPoint
 		switch directionString {
 		case "up":
-			targetOffset = CGPoint(x: 0, y: pixels)
+			targetOffset = CGPoint(x: 0, y: points)
 			break;
 		case "down":
-			targetOffset = CGPoint(x: 0, y: -pixels)
+			targetOffset = CGPoint(x: 0, y: -points)
 			break;
 		case "left":
-			targetOffset = CGPoint(x: pixels, y: 0)
+			targetOffset = CGPoint(x: points, y: 0)
 			break;
 		case "right":
-			targetOffset = CGPoint(x: -pixels, y: 0)
+			targetOffset = CGPoint(x: -points, y: 0)
 			break;
 		default:
 			fatalError("Unknown scroll direction")
@@ -318,26 +318,26 @@ class ScrollAction : Action {
 class ScrollToEdgeAction : Action {
 	override func perform(on element: Element)  -> [String: Any]? {
 		let directionString = params![0] as! String
-		let targetOffset : CGPoint
+		let targetEdge : UIRectEdge
 		switch directionString {
 		case "top":
-			targetOffset = CGPoint(x: 0, y: -1)
+			targetEdge = .top
 			break;
 		case "bottom":
-			targetOffset = CGPoint(x: 0, y: 1)
+			targetEdge = .bottom
 			break;
 		case "left":
-			targetOffset = CGPoint(x: -1, y: 0)
+			targetEdge = .left
 			break;
 		case "right":
-			targetOffset = CGPoint(x: 1, y: 0)
+			targetEdge = .right
 			break;
 		default:
 			fatalError("Unknown scroll direction")
 			break;
 		}
 		
-		element.scroll(toNormalizedEdge: targetOffset)
+		element.scroll(to: targetEdge)
 		
 		return nil
 	}
diff --git a/detox/ios/Detox/Invocation/Element.swift b/detox/ios/Detox/Invocation/Element.swift
index 8f0ca5989..0593fd46a 100644
--- a/detox/ios/Detox/Invocation/Element.swift
+++ b/detox/ios/Detox/Invocation/Element.swift
@@ -66,7 +66,7 @@ class Element : NSObject {
 		let element : UIView
 		if let index = index {
 			guard index < array.count else {
-				dtx_fatalError("Index \(index) beyond bounds [0 .. \(array.count - 1)] for “\(self.description)”", viewDescription: failDebugAttributes)
+				dtx_fatalError("Index \(index) beyond bounds \(array.count > 0 ? "[0 .. \(array.count - 1)] " : " ")for “\(self.description)”", viewDescription: failDebugAttributes)
 			}
 			element = array[index]
 		} else {
@@ -145,10 +145,10 @@ class Element : NSObject {
 		view.dtx_pinch(withScale: scale, velocity: velocity, angle: angle)
 	}
 	
-	func scroll(toNormalizedEdge edge: CGPoint) {
+	func scroll(to edge: UIRectEdge) {
 		let scrollView = extractScrollView()
 		
-		scrollView.dtx_scroll(toNormalizedEdge: edge)
+		scrollView.dtx_scroll(to: edge)
 	}
 
 	func scroll(withOffset offset: CGPoint, normalizedStartingPoint: CGPoint? = nil) {
diff --git a/detox/ios/Detox/Policy/DetoxPolicy.h b/detox/ios/Detox/Policy/DetoxPolicy.h
index 8a09fe526..5d3ad26a7 100644
--- a/detox/ios/Detox/Policy/DetoxPolicy.h
+++ b/detox/ios/Detox/Policy/DetoxPolicy.h
@@ -17,6 +17,8 @@ NS_ASSUME_NONNULL_BEGIN
 @property (nonatomic, readonly) CGFloat visibilityPixelAlphaThreshold;
 @property (nonatomic, readonly) CGFloat visibilityVisiblePixelRatioThreshold;
 
+@property (nonatomic, readonly) NSUInteger consecutiveTouchPointsWithSameContentOffsetThreshold;
+
 + (NSString*)descriptionForDouble:(CGFloat)number;
 @property (nonatomic, copy, readonly) NSString* visibilityVisiblePixelRatioThresholdDescription;
 
diff --git a/detox/ios/Detox/Policy/DetoxPolicy.m b/detox/ios/Detox/Policy/DetoxPolicy.m
index 57f1dbacb..3c7d0694b 100644
--- a/detox/ios/Detox/Policy/DetoxPolicy.m
+++ b/detox/ios/Detox/Policy/DetoxPolicy.m
@@ -32,6 +32,11 @@ static DetoxPolicy* _activePolicy;
 	return 0.75;
 }
 
+- (NSUInteger)consecutiveTouchPointsWithSameContentOffsetThreshold
+{
+	return 2;
+}
+
 + (NSString*)descriptionForDouble:(CGFloat)number
 {
 	static NSNumberFormatter* formatter;
diff --git a/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeSupport.m b/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeSupport.m
index 6971c9f74..dca6f53cc 100644
--- a/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeSupport.m
+++ b/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeSupport.m
@@ -25,6 +25,24 @@
 
 DTX_CREATE_LOG(ReactNativeSupport);
 
+@interface NSObject (DTXRNFix) @end
+@implementation NSObject (DTXRNFix)
+
+//Disable live reload for Detox
+- (void)__detox_sync__reloadWithDefaults:(NSDictionary *)defaultValues
+{
+	NSMutableDictionary* dv = [defaultValues mutableCopy];
+	dv[@"hotLoadingEnabled"] = @NO;
+	
+	[self __detox_sync__reloadWithDefaults:defaultValues];
+	
+	NSMutableDictionary* _settings = [self valueForKey:@"_settings"];
+	_settings[@"hotLoadingEnabled"] = @NO;
+	[NSUserDefaults.standardUserDefaults setObject:_settings forKey:@"RCTDevMenu"];
+}
+
+@end
+
 static NSString *const RCTReloadNotification = @"RCTReloadNotification";
 
 static dispatch_queue_t __currentIdlingResourceSerialQueue;
@@ -221,6 +239,12 @@ static void __setupRNSupport()
 		[[GREYUIThreadExecutor sharedInstance] registerIdlingResource:[WXAnimatedDisplayLinkIdlingResource new]];
 	}
 	
+	cls = NSClassFromString(@"RCTDevSettingsUserDefaultsDataSource");
+	if(cls != nil)
+	{
+		DTXSwizzleMethod(cls, NSSelectorFromString(@"_reloadWithDefaults:"), @selector(__detox_sync__reloadWithDefaults:), NULL);
+	}
+	
 	//🤦‍♂️ RN doesn't set the data source and relies on undocumented behavior.
 	cls = NSClassFromString(@"RCTPicker");
 	if(cls != nil)
diff --git a/detox/ios/Detox/Utilities/UIView+DetoxUtils.m b/detox/ios/Detox/Utilities/UIView+DetoxUtils.m
index d6cc684fd..2edb33568 100644
--- a/detox/ios/Detox/Utilities/UIView+DetoxUtils.m
+++ b/detox/ios/Detox/Utilities/UIView+DetoxUtils.m
@@ -448,6 +448,13 @@ DTX_DIRECT_MEMBERS
 	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]);
diff --git a/detox/ios/Detox/Utilities/UIView+Drawing.m b/detox/ios/Detox/Utilities/UIView+Drawing.m
index 2be5427b8..376a543f7 100644
--- a/detox/ios/Detox/Utilities/UIView+Drawing.m
+++ b/detox/ios/Detox/Utilities/UIView+Drawing.m
@@ -119,14 +119,20 @@ CALayer* _DTXLayerForView(UIView* view, BOOL afterUpdates)
 		return;
 	}
 	
-	for (UIView* obj in self.subviews.reverseObjectEnumerator) {
+	NSArray* zSorted = [self.subviews.reverseObjectEnumerator.allObjects sortedArrayWithOptions:NSSortStable usingComparator:^NSComparisonResult(UIView* _Nonnull obj1, UIView* _Nonnull obj2) {
+		return [@(obj2.layer.zPosition) compare:@(obj1.layer.zPosition)];
+	}];
+	
+	for (UIView* obj in zSorted) {
 		if([subview isDescendantOfView:obj] == NO)
 		{
 			CGContextSaveGState(ctx);
 			CGRect bounds = obj.bounds;
 			CGRect boundsInRootViewCoords = [rootView convertRect:bounds fromView:obj];
 			CGContextTranslateCTM(ctx, boundsInRootViewCoords.origin.x, boundsInRootViewCoords.origin.y);
-			[_DTXLayerForView(obj, afterUpdates) renderInContext:ctx];
+			CALayer* layer = _DTXLayerForView(obj, afterUpdates);
+			CGContextConcatCTM(ctx, [layer affineTransform]);
+			[layer renderInContext:ctx];
 			CGContextRestoreGState(ctx);
 		}
 		else
diff --git a/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.m b/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.m
index 4582fb13b..1a6933f16 100644
--- a/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.m
+++ b/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.m
@@ -42,7 +42,15 @@
 	if (@available(iOS 13.0, *))
 	{
 		scene = scene ?: UIWindowScene._keyWindowScene;
-		[windows filterUsingPredicate:[NSPredicate predicateWithFormat:@"windowScene == %@", scene]];
+		NSPredicate* predicate = [NSPredicate predicateWithFormat:@"windowScene == %@", scene];
+		
+		UIScene* keyboardScene = [UIWindowScene _keyboardWindowSceneForScreen:[scene screen] create:NO];
+		if(keyboardScene != nil)
+		{
+			predicate = [NSCompoundPredicate orPredicateWithSubpredicates:@[predicate, [NSPredicate predicateWithFormat:@"windowScene == %@", keyboardScene]]];
+		}
+		
+		[windows filterUsingPredicate:predicate];
 	}
 	
 	return windows;
diff --git a/detox/ios/EarlGrey b/detox/ios/EarlGrey
index 80fd675eb..d4a69a00a 160000
--- a/detox/ios/EarlGrey
+++ b/detox/ios/EarlGrey
@@ -1,1 +1,1 @@
-Subproject commit 80fd675ebfd7691f3d5334d602065825d6b69a5f
+Subproject commit d4a69a00a7e0b8ba43bdc1e80e79ec709da4bd11
diff --git a/detox/ios/SocketRocket b/detox/ios/SocketRocket
index 8457d4b92..5bbf7c682 160000
--- a/detox/ios/SocketRocket
+++ b/detox/ios/SocketRocket
@@ -1,1 +1,1 @@
-Subproject commit 8457d4b92618b835abe0a2232e1a5c5d9a46eb1e
+Subproject commit 5bbf7c682696befdbae1721a1751695cb5dc0b03
diff --git a/detox/package.json b/detox/package.json
index 54443ab2d..d592531da 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -1,7 +1,7 @@
 {
   "name": "detox",
   "description": "E2E tests and automation for mobile",
-  "version": "17.8.5",
+  "version": "17.9.0",
   "bin": {
     "detox": "local-cli/cli.js"
   },
diff --git a/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.js b/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.js
index 32225bb2f..49ec075d6 100644
--- a/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.js
+++ b/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.js
@@ -23,7 +23,10 @@ class IosUIHierarchyPlugin extends ArtifactPlugin {
 
   async onBeforeLaunchApp(event) {
     await super.onBeforeLaunchApp(event);
-    event.launchArgs['detoxDisableHierarchyDump'] = !this.enabled;
+
+    if (!this.enabled && !event.launchArgs.hasOwnProperty('detoxDisableHierarchyDump')) {
+      event.launchArgs['detoxDisableHierarchyDump'] = 'YES';
+    }
   }
 
   async onCreateExternalArtifact(e) {
diff --git a/detox/src/configuration/collectCliConfig.js b/detox/src/configuration/collectCliConfig.js
index fde6a9dbe..ea99362c4 100644
--- a/detox/src/configuration/collectCliConfig.js
+++ b/detox/src/configuration/collectCliConfig.js
@@ -17,6 +17,7 @@ function collectCliConfig({ argv }) {
     configuration: get('configuration'),
     debugSynchronization: get('debug-synchronization'),
     deviceLaunchArgs: get('device-launch-args'),
+    appLaunchArgs: get('app-launch-args'),
     deviceName: get('device-name'),
     forceAdbInstall: get('force-adb-install'),
     gpu: get('gpu'),
diff --git a/detox/src/configuration/composeDeviceConfig.js b/detox/src/configuration/composeDeviceConfig.js
index 22dae7e3f..d0592c201 100644
--- a/detox/src/configuration/composeDeviceConfig.js
+++ b/detox/src/configuration/composeDeviceConfig.js
@@ -1,4 +1,5 @@
 const _ = require('lodash');
+const parse = require('yargs/yargs').Parser;
 
 function validateType({ errorBuilder, rawDeviceConfig }) {
   if (!rawDeviceConfig || !rawDeviceConfig.type) {
@@ -14,12 +15,49 @@ function getValidatedDeviceName({ errorBuilder, rawDeviceConfig, cliConfig }) {
   return device;
 }
 
+function validateAppLaunchArgs({ errorBuilder, rawDeviceConfig }) {
+  if (!rawDeviceConfig.launchArgs) {
+    return;
+  }
+
+  if (!_.isObject(rawDeviceConfig.launchArgs)) {
+    throw errorBuilder.malformedAppLaunchArgs();
+  }
+
+  const nonStringPropertyName = _.chain(rawDeviceConfig.launchArgs)
+    .entries()
+    .find(([key, value]) => value != null && !_.isString(value))
+    .thru((entry) => entry ? entry[0] : null)
+    .value()
+
+  if (nonStringPropertyName) {
+    throw errorBuilder.malformedAppLaunchArgsProperty(nonStringPropertyName);
+  }
+}
+
 function validateUtilBinaryPaths({ errorBuilder, rawDeviceConfig }) {
   if (rawDeviceConfig.utilBinaryPaths && !_.isArray(rawDeviceConfig.utilBinaryPaths)) {
     throw errorBuilder.malformedUtilBinaryPaths();
   }
 }
 
+function mergeAppLaunchArgsFromCLI(deviceConfig, cliConfig) {
+  if (!cliConfig.appLaunchArgs) {
+    return;
+  }
+
+  deviceConfig.launchArgs = _.chain({})
+    .thru(() => parse(cliConfig.appLaunchArgs, {
+      configuration: {
+        'short-option-groups': false,
+      },
+    }))
+    .omit(['_', '--'])
+    .defaults(deviceConfig.launchArgs)
+    .omitBy(value => value === false)
+    .value();
+}
+
 /**
  *
  * @param {DetoxConfigErrorBuilder} errorBuilder
@@ -29,10 +67,13 @@ function validateUtilBinaryPaths({ errorBuilder, rawDeviceConfig }) {
  */
 function composeDeviceConfig({ errorBuilder, rawDeviceConfig, cliConfig }) {
   validateType({ errorBuilder, rawDeviceConfig });
+  validateAppLaunchArgs({ errorBuilder, rawDeviceConfig });
+  mergeAppLaunchArgsFromCLI(rawDeviceConfig, cliConfig);
 
   rawDeviceConfig.device = getValidatedDeviceName({ errorBuilder, rawDeviceConfig, cliConfig });
   delete rawDeviceConfig.name;
 
+
   validateUtilBinaryPaths({ errorBuilder, rawDeviceConfig });
   return rawDeviceConfig;
 }
diff --git a/detox/src/devices/Device.js b/detox/src/devices/Device.js
index 13c598582..aef822896 100644
--- a/detox/src/devices/Device.js
+++ b/detox/src/devices/Device.js
@@ -31,6 +31,7 @@ class Device {
     }
 
     const baseLaunchArgs = {
+      ...this._deviceConfig.launchArgs,
       ...params.launchArgs,
     };
 
diff --git a/detox/src/errors/DetoxConfigErrorBuilder.js b/detox/src/errors/DetoxConfigErrorBuilder.js
index c6e3fb500..b775a4677 100644
--- a/detox/src/errors/DetoxConfigErrorBuilder.js
+++ b/detox/src/errors/DetoxConfigErrorBuilder.js
@@ -87,6 +87,24 @@ class DetoxConfigErrorBuilder {
     });
   }
 
+  malformedAppLaunchArgs() {
+    return new DetoxConfigError({
+      message: `Invalid type of "launchArgs" property in detox.configurations["${this.configurationName}"]\nExpected an object.`,
+      hint: `Check that in your Detox config${_atPath(this.filepath)}`,
+      debugInfo: this._focusOnConfiguration(),
+      inspectOptions: { depth: 2 },
+    });
+  }
+
+  malformedAppLaunchArgsProperty(argKey) {
+    return new DetoxConfigError({
+      message: `Invalid property "${argKey}" inside detox.configurations["${this.configurationName}"].launchArgs\nExpected a string.`,
+      hint: `Check that in your Detox config${_atPath(this.filepath)}`,
+      debugInfo: this._focusOnConfiguration(c => _.pick(c, ['launchArgs'])),
+      inspectOptions: { depth: 3 },
+    });
+  }
+
   malformedUtilBinaryPaths() {
     return new DetoxConfigError({
       message: `Invalid type of "utilBinaryPaths" property in detox.configurations["${this.configurationName}"]\nExpected an array of strings of paths.`,
diff --git a/detox/src/utils/argparse.js b/detox/src/utils/argparse.js
index 2f1a5939b..1d759be35 100644
--- a/detox/src/utils/argparse.js
+++ b/detox/src/utils/argparse.js
@@ -1,6 +1,7 @@
 const _ = require('lodash');
 const argv = require('minimist')(process.argv.slice(2));
 const {escape} = require('./pipeCommands');
+const toUpper = (s) => s.toUpperCase();
 
 function getArgValue(key) {
   let value;
@@ -8,8 +9,15 @@ function getArgValue(key) {
   if (argv && argv[key]) {
     value = argv[key];
   } else {
-    const camelCasedKey = key.replace(/(\-\w)/g, (m) => m[1].toUpperCase());
-    value = process.env[camelCasedKey];
+    const resolvedKey = resolveProcessEnvKey([
+      toUpper(`DETOX_${_.snakeCase(key)}`),
+      _.camelCase(key),
+    ]);
+
+    if (resolvedKey !== undefined) {
+      value = process.env[resolvedKey];
+    }
+
     if (value === 'undefined') {
       value = undefined;
     }
@@ -18,6 +26,19 @@ function getArgValue(key) {
   return value;
 }
 
+function resolveProcessEnvKey(searchedKeys) {
+  let envKeys = _.keys(process.env);
+
+  /* istanbul ignore next */
+  if (process.platform === 'win32') {
+    searchedKeys = searchedKeys.map(toUpper);
+    envKeys = envKeys.map(toUpper);
+  }
+
+  const keySet = new Set(envKeys);
+  return searchedKeys.find(key => keySet.has(key));
+}
+
 function getFlag(key) {
   if (argv && argv[key]) {
     return true;
diff --git a/docs/APIRef.ActionsOnElement.md b/docs/APIRef.ActionsOnElement.md
index 11ee3fc00..d48dc56c1 100644
--- a/docs/APIRef.ActionsOnElement.md
+++ b/docs/APIRef.ActionsOnElement.md
@@ -23,7 +23,7 @@ Use [expectations](APIRef.Expect.md) to verify element states.
 - [`.setDatePickerDate()`](#setdatepickerdatedatestring-dateformat--ios-only) **iOS only**
 - [`.adjustSliderToPosition()`](#adjustslidertopositionnormalizedposition--ios-only) **iOS only**
 - [`.getAttributes()`](#getAttributes--ios-only) **iOS only**
-- [`.takeScreenshot([name])`](#takescreenshotname-android-only) **Android only**
+- [`.takeScreenshot(name)`](#takescreenshotname-android-only) **Android only**
 
 ### `tap(point)`
 
@@ -227,7 +227,10 @@ Retrieved attributes are:
 - `safeAreaInsets`—the safe area insets of the element, in element coordinate space <br/>
 - `elementSafeBounds`—the safe area bounds of the element, in element coordinate space <br/>
 - `date`—the date of the element (in case the element is a date picker) <br/>
-- `normalizedSliderPosition`—the normalized slider position (in case the element is a slider)
+- `normalizedSliderPosition`—the normalized slider position (in case the element is a slider) <br/>
+- `contentOffset`—the content offset (in case the element is a scroll view) <br/>
+- `contentInset`—the content inset (in case the element is a scroll view) <br/>
+- `adjustedContentInset`—the adjusted content inset (in case the element is a scroll view) <br/>
 
 If the value for a given attribute is null or cannot be otherwise computed, the key will not be present, but empty strings may be found in the object.
 
@@ -242,9 +245,11 @@ jestExpect(multipleMatchedElements.elements.length).toBe(5);
 jestExpect(multipleMatchedElements.elements[0].identifier).toBe('FirstElement');
 ```
 
-### `takeScreenshot([name])` Android Only
+### `takeScreenshot(name)` Android Only
 
-Takes a screenshot containing the matched element. For full details on taking screenshots with Detox, refer to the [screen-shots guide](APIRef.Screenshots.md).
+Takes a screenshot of the matched element. For full details on taking screenshots with Detox, refer to the [screenshots guide](APIRef.Screenshots.md).
+
+`name`—the name of the screenshot
 
 ## Deprecated Methods
 
diff --git a/docs/APIRef.DetoxCLI.md b/docs/APIRef.DetoxCLI.md
index 0dd8b4a5e..cb0b4de2e 100644
--- a/docs/APIRef.DetoxCLI.md
+++ b/docs/APIRef.DetoxCLI.md
@@ -85,6 +85,7 @@ Initiating your test suite. <sup>[[1]](#notice-passthrough)</sup>
 | -H, --headless                                | [Android Only] Launch Emulator in headless mode. Useful when running on CI. |
 | --gpu                                         | [Android Only] Launch Emulator with the specific -gpu [gpu mode] parameter. |
 | --device-launch-args | A list of passthrough-arguments to use when (if) devices (Android emulator / iOS simulator) are launched by Detox.<br />**Note: the value must be specified after an equal sign (`=`) and inside quotes.** Usage example:<br />`--device-launch-args="-http-proxy http://1.1.1.1:8000 -no-snapshot-load"` |
+| --app-launch-args | Custom arguments to pass (through) onto the app every time it is launched. The same **note** applies as for **--device-launch-args**. |
 | --no-color                                    | Disable colors in log output |
 | --use-custom-logger | Use Detox' custom console-logging implementation, for logging Detox (non-device) logs. Disabling will fallback to node.js / test-runner's implementation (e.g. Jest / Mocha).<br />*Default: true* |
 | --force-adb-install | Due to problems with the `adb install` command on Android, Detox resorts to a different scheme for install APK's. Setting true will disable that and force usage of `adb install`, instead.<br/>This flag is temporary until the Detox way proves stable.<br/>*Default: false* |
diff --git a/docs/Introduction.AndroidDevEnv.md b/docs/Introduction.AndroidDevEnv.md
index 8589d5e0c..1c81456f3 100644
--- a/docs/Introduction.AndroidDevEnv.md
+++ b/docs/Introduction.AndroidDevEnv.md
@@ -1,4 +1,4 @@
-# Setting Up an Android Development & Testing Envrionment
+# Setting Up an Android Development & Testing Environment
 
 This guide provides some core practices to follow in setting up a stable, reliable environment for running automated UI tests using Android emulators (using Detox, in particular) -- be it on a personal, _local_ computer, or a powerful CI machine.
 
@@ -237,4 +237,4 @@ emulator: Saving state on exit with session uptime 9423 ms
 > emulator: WARNING: Not saving state: RAM not mapped as shared
 > ```
 >
-> It can be a result of an improper configuration, or an emulator launch where the `-read-only` argument was provided.
\ No newline at end of file
+> It can be a result of an improper configuration, or an emulator launch where the `-read-only` argument was provided.
diff --git a/examples/demo-native-android/package.json b/examples/demo-native-android/package.json
index cb457b706..d7665466f 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.8.5",
+  "version": "17.9.0",
   "private": true,
   "scripts": {
     "packager": "react-native start",
@@ -8,7 +8,7 @@
     "e2e": "mocha e2e --opts ./e2e/mocha.opts"
   },
   "devDependencies": {
-    "detox": "^17.8.5",
+    "detox": "^17.9.0",
     "mocha": "^6.1.3"
   },
   "detox": {}
diff --git a/examples/demo-native-ios/package.json b/examples/demo-native-ios/package.json
index 96452dc0a..277021308 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.8.5",
+  "version": "17.9.0",
   "private": true,
   "devDependencies": {
-    "detox": "^17.8.5",
+    "detox": "^17.9.0",
     "mocha": "^6.1.3"
   },
   "detox": {
diff --git a/examples/demo-plugin/package.json b/examples/demo-plugin/package.json
index 8586999d8..1b0e6edd4 100644
--- a/examples/demo-plugin/package.json
+++ b/examples/demo-plugin/package.json
@@ -1,12 +1,12 @@
 {
   "name": "demo-plugin",
-  "version": "17.8.5",
+  "version": "17.9.0",
   "private": true,
   "scripts": {
     "test:plugin": "detox test --configuration plugin -l verbose"
   },
   "devDependencies": {
-    "detox": "^17.8.5",
+    "detox": "^17.9.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 4104cad7a..8d60ddb0c 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.8.5",
+  "version": "17.9.0",
   "private": true,
   "scripts": {},
   "devDependencies": {
-    "detox": "^17.8.5",
+    "detox": "^17.9.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 a9fc97679..39a6b1dde 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.8.5",
+  "version": "17.9.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.8.5",
+    "detox": "^17.9.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 ec412e8e4..f03d9f481 100644
--- a/examples/demo-react-native/package.json
+++ b/examples/demo-react-native/package.json
@@ -1,6 +1,6 @@
 {
   "name": "example",
-  "version": "17.8.5",
+  "version": "17.9.0",
   "private": true,
   "scripts": {
     "start": "react-native start",
@@ -22,7 +22,7 @@
     "react-native": "0.62.x"
   },
   "devDependencies": {
-    "detox": "^17.8.5",
+    "detox": "^17.9.0",
     "mocha": "^6.1.3"
   }
 }
diff --git a/lerna.json b/lerna.json
index 6bd898e7d..60b2f22f0 100644
--- a/lerna.json
+++ b/lerna.json
@@ -13,7 +13,7 @@
     "generation",
     "."
   ],
-  "version": "17.8.5",
+  "version": "17.9.0",
   "npmClient": "npm",
   "command": {
     "publish": {
diff --git a/package.json b/package.json
index 35d8f312d..e61d780d7 100644
--- a/package.json
+++ b/package.json
@@ -18,5 +18,5 @@
     "semver": "5.x.x",
     "shell-utils": "1.x.x"
   },
-  "version": "17.8.5"
+  "version": "17.9.0"
 }


Test patch:
diff --git a/detox/local-cli/test.js b/detox/local-cli/test.js
index bd07669ed..4383e6541 100644
--- a/detox/local-cli/test.js
+++ b/detox/local-cli/test.js
@@ -137,7 +137,7 @@ function prepareMochaArgs({ cliConfig, runnerArgs, runnerConfig, platform }) {
 
       ...passthrough,
     },
-    env: _.pick(cliConfig, ['deviceLaunchArgs']),
+    env: _.pick(cliConfig, ['appLaunchArgs', 'deviceLaunchArgs']),
     specs: _.isEmpty(specs) ? [runnerConfig.specs] : specs,
   };
 }
@@ -174,6 +174,7 @@ function prepareJestArgs({ cliConfig, runnerArgs, runnerConfig, platform }) {
         'recordTimeline',
         'deviceName',
         'deviceLaunchArgs',
+        'appLaunchArgs',
         'useCustomLogger',
         platform === 'android' && 'forceAdbInstall',
       ])),
diff --git a/detox/local-cli/test.test.js b/detox/local-cli/test.test.js
index 220d99072..df543509f 100644
--- a/detox/local-cli/test.test.js
+++ b/detox/local-cli/test.test.js
@@ -174,6 +174,13 @@ describe('CLI', () => {
       });
     });
 
+    test('--app-launch-args should be passed as an environment variable', async () => {
+      await run(`--app-launch-args "--debug yes"`);
+      expect(cliCall().env).toEqual({
+        appLaunchArgs: '--debug yes',
+      });
+    });
+
     test('--use-custom-logger false should be prevent passing CLI argument', async () => {
       await run(`--use-custom-logger false`);
       expect(cliCall().command).not.toContain('--use-custom-logger');
@@ -518,6 +525,13 @@ describe('CLI', () => {
       }));
     });
 
+    test('--app-launch-args should be passed as an environment variable', async () => {
+      await run(`--app-launch-args "--debug yes"`);
+      expect(cliCall().env).toEqual(expect.objectContaining({
+        appLaunchArgs: '--debug yes',
+      }));
+    });
+
     test('--use-custom-logger false should be prevent passing environment variable', async () => {
       await run(`--use-custom-logger false`);
       expect(cliCall().env).toEqual(expect.objectContaining({
diff --git a/detox/local-cli/utils/testCommandArgs.js b/detox/local-cli/utils/testCommandArgs.js
index e7ba33661..294960255 100644
--- a/detox/local-cli/utils/testCommandArgs.js
+++ b/detox/local-cli/utils/testCommandArgs.js
@@ -129,6 +129,10 @@ module.exports = {
     group: 'Execution:',
     describe: 'Custom arguments to pass (through) onto the device (emulator/simulator) binary when launched.',
   },
+  'app-launch-args': {
+    group: 'Execution:',
+    describe: 'Custom arguments to pass (through) onto the app every time it is launched.',
+  },
   'use-custom-logger': {
     boolean: true,
     default: true,
diff --git a/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.test.js b/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.test.js
index f321667be..89a2a61f3 100644
--- a/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.test.js
+++ b/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.test.js
@@ -96,14 +96,26 @@ describe('IosUIHierarchyPlugin', () => {
       plugin = new IosUIHierarchyPlugin({ api, client });
     });
 
-    it('should propogate launchArg to native', async () => {
+    it('should propagate -detoxDisableHierarchyDump YES to native', async () => {
       const event = {
         launchArgs: {}
       };
 
       await plugin.onTestStart(testSummary);
       await plugin.onBeforeLaunchApp(event);
-      expect(event.launchArgs.detoxDisableHierarchyDump).toBeDefined();
+      expect(event.launchArgs.detoxDisableHierarchyDump).toBe('YES');
+    });
+
+    it('should not propagate -detoxDisableHierarchyDump YES to native if there is a custom value set', async () => {
+      const event = {
+        launchArgs: {
+          detoxDisableHierarchyDump: 'NO'
+        }
+      };
+
+      await plugin.onTestStart(testSummary);
+      await plugin.onBeforeLaunchApp(event);
+      expect(event.launchArgs.detoxDisableHierarchyDump).toBe('NO');
     });
 
     it('should remove the file artifact', () => {
diff --git a/detox/src/configuration/collectCliConfig.test.js b/detox/src/configuration/collectCliConfig.test.js
index 9b5b33294..56a396c2d 100644
--- a/detox/src/configuration/collectCliConfig.test.js
+++ b/detox/src/configuration/collectCliConfig.test.js
@@ -1,66 +1,71 @@
-jest.mock('../utils/argparse');
-
 describe('collectCliConfig', () => {
   let collectCliConfig;
   let argv, env;
+  let __env__;
 
   beforeEach(() => {
+    __env__ = process.env;
+    env = process.env = { ...__env__ };
     argv = {};
-    env = {};
-
-    require('../utils/argparse').getArgValue
-      .mockImplementation(key => env[key]);
 
     collectCliConfig = require('./collectCliConfig');
   });
 
+  afterEach(() => {
+    process.env = __env__;
+  });
+
   describe.each([
-    ['artifactsLocation', 'artifacts-location'],
-    ['recordLogs', 'record-logs'],
-    ['takeScreenshots', 'take-screenshots'],
-    ['recordVideos', 'record-videos'],
-    ['recordPerformance', 'record-performance'],
-    ['recordTimeline', 'record-timeline'],
-    ['cleanup', 'cleanup'],
-    ['configPath', 'config-path'],
-    ['configuration', 'configuration'],
-    ['debugSynchronization', 'debug-synchronization'],
-    ['deviceLaunchArgs', 'device-launch-args'],
-    ['deviceName', 'device-name'],
-    ['forceAdbInstall', 'force-adb-install'],
-    ['gpu', 'gpu'],
-    ['headless', 'headless'],
-    ['jestReportSpecs', 'jest-report-specs'],
-    ['keepLockFile', 'keepLockFile'],
-    ['loglevel', 'loglevel'],
-    ['noColor', 'no-color'],
-    ['reuse', 'reuse'],
-    ['runnerConfig', 'runner-config'],
-    ['useCustomLogger', 'use-custom-logger'],
-    ['workers', 'workers'],
-    ['inspectBrk', 'inspect-brk'],
-  ])('.%s property', (key, argName) => {
+    ['artifactsLocation',    'DETOX_ARTIFACTS_LOCATION',    'artifacts-location'],
+    ['recordLogs',           'DETOX_RECORD_LOGS',           'record-logs'],
+    ['takeScreenshots',      'DETOX_TAKE_SCREENSHOTS',      'take-screenshots'],
+    ['recordVideos',         'DETOX_RECORD_VIDEOS',         'record-videos'],
+    ['recordPerformance',    'DETOX_RECORD_PERFORMANCE',    'record-performance'],
+    ['recordTimeline',       'DETOX_RECORD_TIMELINE',       'record-timeline'],
+    ['cleanup',              'DETOX_CLEANUP',               'cleanup'],
+    ['configPath',           'DETOX_CONFIG_PATH',           'config-path'],
+    ['configuration',        'DETOX_CONFIGURATION',         'configuration'],
+    ['debugSynchronization', 'DETOX_DEBUG_SYNCHRONIZATION', 'debug-synchronization'],
+    ['deviceLaunchArgs',     'DETOX_DEVICE_LAUNCH_ARGS',    'device-launch-args'],
+    ['appLaunchArgs',        'DETOX_APP_LAUNCH_ARGS',       'app-launch-args'],
+    ['deviceName',           'DETOX_DEVICE_NAME',           'device-name'],
+    ['forceAdbInstall',      'DETOX_FORCE_ADB_INSTALL',     'force-adb-install'],
+    ['gpu',                  'DETOX_GPU',                   'gpu'],
+    ['headless',             'DETOX_HEADLESS',              'headless'],
+    ['jestReportSpecs',      'DETOX_JEST_REPORT_SPECS',     'jest-report-specs'],
+    ['keepLockFile',         'DETOX_KEEP_LOCK_FILE',        'keepLockFile'],
+    ['loglevel',             'DETOX_LOGLEVEL',              'loglevel'],
+    ['noColor',              'DETOX_NO_COLOR',              'no-color'],
+    ['reuse',                'DETOX_REUSE',                 'reuse'],
+    ['runnerConfig',         'DETOX_RUNNER_CONFIG',         'runner-config'],
+    ['useCustomLogger',      'DETOX_USE_CUSTOM_LOGGER',     'use-custom-logger'],
+    ['workers',              'DETOX_WORKERS',               'workers'],
+    ['inspectBrk',           'DETOX_INSPECT_BRK',           'inspect-brk'],
+  ])('.%s property' , (key, envName, argName) => {
+    const legacyEnvName = key;
+
     beforeEach(() => {
+      env[legacyEnvName] = Math.random();
+      env[envName] = Math.random();
       argv[argName] = Math.random();
-      env[argName] = Math.random();
     });
 
-    it('should be extracted from argv', () => {
-      argv[argName] = Math.random();
-      expect(collectCliConfig({ argv })[key]).toBe(argv[argName]);
+    it('should be extracted from argv if it is provided', () => {
+      const expected = argv[argName];
+      expect(collectCliConfig({ argv })[key]).toBe(expected);
     });
 
-    it('should be extracted from environment (via argparse)', () => {
-      argv = undefined;
-      env[argName] = Math.random();
+    it('should be extracted from environment otherwise, preferring DETOX_SNAKE_CASE', () => {
+      const expected = env[envName];
 
-      expect(collectCliConfig({ argv })[key]).toBe(env[argName]);
+      expect(collectCliConfig({})[key]).toBe(expected);
     });
 
-    it('should be extracted from argv, even if argparse has it too', () => {
-      argv[argName] = Math.random();
-      env[argName] = Math.random();
-      expect(collectCliConfig({ argv })[key]).toBe(argv[argName]);
+    it('should be extracted from environment otherwise, when there is no DETOX_SNAKE_CASE, only camelCase', () => {
+      delete env[envName];
+      const expected = env[legacyEnvName];
+
+      expect(collectCliConfig({})[key]).toBe(expected);
     });
   });
-});
\ No newline at end of file
+});
diff --git a/detox/src/configuration/composeDeviceConfig.test.js b/detox/src/configuration/composeDeviceConfig.test.js
index 30585f33a..cf6b5c53d 100644
--- a/detox/src/configuration/composeDeviceConfig.test.js
+++ b/detox/src/configuration/composeDeviceConfig.test.js
@@ -46,6 +46,21 @@ describe('composeDeviceConfig', () => {
       rawDeviceConfig.utilBinaryPaths = 'valid/path/not/in/array';
       expect(compose).toThrowError(errorBuilder.malformedUtilBinaryPaths());
     });
+
+    it('should throw if app launch args is a string', () => {
+      rawDeviceConfig.launchArgs = '-detoxAppArgument NO';
+      expect(compose).toThrowError(errorBuilder.malformedAppLaunchArgs());
+    });
+
+    it('should not throw if app launch args has an undefined property', () => {
+      rawDeviceConfig.launchArgs = { arg: undefined };
+      expect(compose).not.toThrowError();
+    });
+
+    it('should throw if app launch args has a non-string property', () => {
+      rawDeviceConfig.launchArgs = { debugSomething: false };
+      expect(compose).toThrowError(errorBuilder.malformedAppLaunchArgsProperty('debugSomething'));
+    });
   });
 
   describe('if a device configuration has the old .name property', () => {
@@ -88,4 +103,37 @@ describe('composeDeviceConfig', () => {
       });
     });
   });
+
+  describe('if a device configuration has .launchArgs property', () => {
+    beforeEach(() => {
+      rawDeviceConfig.launchArgs = {
+        arg1: 'value 1',
+        arg2: 'value 2',
+      };
+    });
+
+    it('should be left intact', () => {
+      const { launchArgs } = compose();
+
+      expect(launchArgs).toEqual({
+        arg1: 'value 1',
+        arg2: 'value 2',
+      });
+    });
+
+    describe('and there is a CLI override', () => {
+      beforeEach(() => {
+        cliConfig.appLaunchArgs = '-arg3="value 3" --no-arg2';
+      });
+
+      it('should parse it and merge the values inside', () => {
+        const { launchArgs } = compose();
+
+        expect(launchArgs).toEqual({
+          arg1: 'value 1',
+          arg3: 'value 3',
+        });
+      });
+    });
+  });
 });
diff --git a/detox/src/errors/DetoxConfigErrorBuilder.test.js b/detox/src/errors/DetoxConfigErrorBuilder.test.js
index 71d167894..c6ad942e6 100644
--- a/detox/src/errors/DetoxConfigErrorBuilder.test.js
+++ b/detox/src/errors/DetoxConfigErrorBuilder.test.js
@@ -143,6 +143,51 @@ describe('DetoxConfigErrorBuilder', () => {
     });
   });
 
+  describe('.malformedAppLaunchArgs', () => {
+    beforeEach(() => {
+      build = () => builder.malformedAppLaunchArgs();
+      builder.setConfigurationName('android.release');
+      builder.setDetoxConfig({
+        configurations: {
+          'android.release': {
+            type: 'android.emulator',
+            utilBinaryPaths: '/valid/path/outside/of/array',
+            device: 'Pixel 4',
+            launchArgs: 'do not use strings here please',
+          },
+        },
+      });
+    });
+
+    it('should create an error with specifying the config name', () => {
+      expect(build()).toMatchSnapshot();
+    });
+  });
+
+  describe('.malformedAppLaunchArgsProperty', () => {
+    beforeEach(() => {
+      build = () => builder.malformedAppLaunchArgsProperty('invalidFalseProperty');
+      builder.setConfigurationName('android.release');
+      builder.setDetoxConfig({
+        configurations: {
+          'android.release': {
+            type: 'android.emulator',
+            utilBinaryPaths: '/valid/path/outside/of/array',
+            device: 'Pixel 4',
+            launchArgs: {
+              validFalseProperty: 'false',
+              invalidFalseProperty: false,
+            },
+          },
+        },
+      });
+    });
+
+    it('should create an error with specifying the exact launch arg', () => {
+      expect(build()).toMatchSnapshot();
+    });
+  });
+
   describe('.malformedUtilBinaryPaths', () => {
     beforeEach(() => {
       build = () => builder.malformedUtilBinaryPaths()
diff --git a/detox/src/errors/__snapshots__/DetoxConfigErrorBuilder.test.js.snap b/detox/src/errors/__snapshots__/DetoxConfigErrorBuilder.test.js.snap
index c7842785b..974d54b56 100644
--- a/detox/src/errors/__snapshots__/DetoxConfigErrorBuilder.test.js.snap
+++ b/detox/src/errors/__snapshots__/DetoxConfigErrorBuilder.test.js.snap
@@ -29,6 +29,42 @@ exports[`DetoxConfigErrorBuilder .failedToReadConfiguration should create a simp
 EISDIR: illegal operation on a directory, read]
 `;
 
+exports[`DetoxConfigErrorBuilder .malformedAppLaunchArgs should create an error with specifying the config name 1`] = `
+[DetoxConfigError: Invalid type of "launchArgs" property in detox.configurations["android.release"]
+Expected an object.
+
+HINT: Check that in your Detox config.
+
+{
+  configurations: {
+    'android.release': {
+      type: 'android.emulator',
+      utilBinaryPaths: '/valid/path/outside/of/array',
+      device: 'Pixel 4',
+      launchArgs: 'do not use strings here please'
+    }
+  }
+}]
+`;
+
+exports[`DetoxConfigErrorBuilder .malformedAppLaunchArgsProperty should create an error with specifying the exact launch arg 1`] = `
+[DetoxConfigError: Invalid property "invalidFalseProperty" inside detox.configurations["android.release"].launchArgs
+Expected a string.
+
+HINT: Check that in your Detox config.
+
+{
+  configurations: {
+    'android.release': {
+      launchArgs: {
+        validFalseProperty: 'false',
+        invalidFalseProperty: false
+      }
+    }
+  }
+}]
+`;
+
 exports[`DetoxConfigErrorBuilder .malformedUtilBinaryPaths should create an error with specifying the config name 1`] = `
 [DetoxConfigError: Invalid type of "utilBinaryPaths" property in detox.configurations["android.release"]
 Expected an array of strings of paths.
diff --git a/detox/src/utils/argparse.test.js b/detox/src/utils/argparse.test.js
index 3a7da06ef..e696b74a0 100644
--- a/detox/src/utils/argparse.test.js
+++ b/detox/src/utils/argparse.test.js
@@ -3,23 +3,40 @@ jest.unmock('process');
 describe('argparse', () => {
   describe('getArgValue()', () => {
     describe('using env variables', () => {
+      let _env;
       let argparse;
 
       beforeEach(() => {
-        process.env.fooBar = 'a value';
-        process.env.testUndefinedProp = 'undefined';
+        _env = process.env;
+
+        process.env = {
+          ..._env,
+          fooBar: 'legacy env format',
+          DETOX_FOO_BAR: 'new env format',
+          testUndefinedProp: 'undefined',
+        };
+
         argparse = require('./argparse');
       });
 
-      it(`nonexistent key should return undefined result`, () => {
+      afterEach(() => {
+        process.env = _env;
+      });
+
+      it(`nonexistent key should return undefined`, () => {
         expect(argparse.getArgValue('blah')).not.toBeDefined();
       });
 
-      it(`existing key should return a result`, () => {
-        expect(argparse.getArgValue('foo-bar')).toBe('a value');
+      it(`existing key should return DETOX_SNAKE_FORMAT value first`, () => {
+        expect(argparse.getArgValue('foo-bar')).toBe('new env format');
+      });
+
+      it(`existing key should return legacyCamelFormat value if there is no DETOX_SNAKE_FORMAT`, () => {
+        delete process.env.DETOX_FOO_BAR;
+        expect(argparse.getArgValue('foo-bar')).toBe('legacy env format');
       });
 
-      it('should return undefiend if process.env contain something with a string of undefiend' ,() => {
+      it('should return undefined if process.env contain something with a string of undefined' ,() => {
         expect(argparse.getArgValue('testUndefinedProp')).toBe(undefined);
       });
     });
diff --git a/detox/test/android/app/src/main/java/com/example/NativeModule.java b/detox/test/android/app/src/main/java/com/example/NativeModule.java
index 658a2cad6..d5a2ebd37 100644
--- a/detox/test/android/app/src/main/java/com/example/NativeModule.java
+++ b/detox/test/android/app/src/main/java/com/example/NativeModule.java
@@ -118,7 +118,6 @@ public class NativeModule extends ReactContextBaseJavaModule {
     public void toggleNonStorageSynchronization(Boolean enable) {
         ReactNativeExtensionReflected rnExtension = ReactNativeExtensionReflected.getInstance();
         rnExtension.toggleUISynchronization(enable);
-        rnExtension.toggleJSBridgeSynchronization(enable);
         rnExtension.toggleTimersSynchronization(enable);
         rnExtension.toggleNetworkSynchronization(enable);
     }
diff --git a/detox/test/android/app/src/main/java/com/example/utils/ReactNativeExtensionReflected.java b/detox/test/android/app/src/main/java/com/example/utils/ReactNativeExtensionReflected.java
index e2961d303..35191e38a 100644
--- a/detox/test/android/app/src/main/java/com/example/utils/ReactNativeExtensionReflected.java
+++ b/detox/test/android/app/src/main/java/com/example/utils/ReactNativeExtensionReflected.java
@@ -4,7 +4,6 @@ import java.lang.reflect.Method;
 
 public class ReactNativeExtensionReflected {
     private final Method toggleUISynchronization;
-    private final Method toggleJSBridgeSynchronization;
     private final Method toggleTimersSynchronization;
     private final Method toggleNetworkSynchronization;
 
@@ -20,7 +19,6 @@ public class ReactNativeExtensionReflected {
         try {
             Class<?> clazz = Class.forName("com.wix.detox.reactnative.ReactNativeExtension");
             toggleUISynchronization = clazz.getDeclaredMethod("toggleUISynchronization", boolean.class);
-            toggleJSBridgeSynchronization = clazz.getDeclaredMethod("toggleJSBridgeSynchronization", boolean.class);
             toggleTimersSynchronization = clazz.getDeclaredMethod("toggleTimersSynchronization", boolean.class);
             toggleNetworkSynchronization = clazz.getDeclaredMethod("toggleNetworkSynchronization", boolean.class);
         } catch (Exception e) {
@@ -32,10 +30,6 @@ public class ReactNativeExtensionReflected {
         invokeToggleMethod(toggleUISynchronization, enable);
     }
 
-    public void toggleJSBridgeSynchronization(boolean enable) {
-        invokeToggleMethod(toggleJSBridgeSynchronization, enable);
-    }
-
     public void toggleTimersSynchronization(boolean enable) {
         invokeToggleMethod(toggleTimersSynchronization, enable);
     }
diff --git a/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme b/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme
index a2e4a12e6..f816ed666 100644
--- a/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme
+++ b/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
-   LastUpgradeVersion = "1200"
+   LastUpgradeVersion = "1220"
    version = "1.3">
    <BuildAction
       parallelizeBuildables = "NO"
diff --git a/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example_ci.xcscheme b/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example_ci.xcscheme
index 8ad3dfafb..ac388e69f 100644
--- a/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example_ci.xcscheme
+++ b/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example_ci.xcscheme
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
-   LastUpgradeVersion = "1200"
+   LastUpgradeVersion = "1220"
    version = "1.3">
    <BuildAction
       parallelizeBuildables = "NO"
diff --git a/detox/test/package.json b/detox/test/package.json
index a67fd51a2..0caef289f 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-test",
-  "version": "17.8.5",
+  "version": "17.9.0",
   "private": true,
   "engines": {
     "node": ">=8.3.0"
@@ -31,7 +31,7 @@
   },
   "devDependencies": {
     "@babel/core": "^7.8.7",
-    "detox": "^17.8.5",
+    "detox": "^17.9.0",
     "express": "^4.15.3",
     "jest": "^26.5.0",
     "jest-circus": "^26.5.2",
diff --git a/detox/test/src/helpers/storage.js b/detox/test/src/helpers/storage.js
index 6e398509f..b22f218d2 100644
--- a/detox/test/src/helpers/storage.js
+++ b/detox/test/src/helpers/storage.js
@@ -1,11 +1,12 @@
 import AsyncStorage from '@react-native-community/async-storage';
 
-const SET_AND_GET_ITERATIONS = 100;
-const GLOBAL_ITERATIONS = 3;
+const SET_AND_GET_ITERATIONS = 50;
+const GLOBAL_ITERATIONS = 4;
 
 export async function runStressTest() {
-  let keyCount = 0;
-  for (let globalCount = 0; globalCount < GLOBAL_ITERATIONS; globalCount++, keyCount += 10) {
+  for (let globalCount = 1, keyCount = 10;
+        globalCount <= GLOBAL_ITERATIONS;
+        globalCount++, keyCount += 10) {
     console.log(`Storage@Stress Global iteration #${globalCount}`);
     await AsyncStorage.clear();