Repository: wix/Detox
Pull request #2134 (2020-06-16 15:38:29)

Introduce basic support for notification-triggers in device.launchApp()
- [ ] This is a small change 
- [x] This change has been discussed in issue #1192 and the solution has been agreed upon with maintainers.

---

**Description:**
Partially implements #1192 by adding support for the core use case, as conveyed by this API:
```js
    const userNotification = {
      payload: {
        userData1: 'userDataValue1',
        userData2: 'userDataValue2',
      },
    };
    await device.launchApp({ newInstance: true, userNotification });
```

Gaps to fill in after merge:
- [ ] Add support for handling notification when app is in background (i.e. move to foreground with notification data)
- [ ] Add support for `sendUserNotification()` API


Patch:
diff --git a/detox/android/detox/build.gradle b/detox/android/detox/build.gradle
index 6a6b24e2b..71c8e8ac6 100644
--- a/detox/android/detox/build.gradle
+++ b/detox/android/detox/build.gradle
@@ -23,6 +23,7 @@ android {
     }
 
     testOptions {
+        unitTests.includeAndroidResources = true
         unitTests.returnDefaultValues = true
 
         unitTests.all { t ->
@@ -104,6 +105,7 @@ dependencies {
     testImplementation "org.jetbrains.kotlin:kotlin-test:$_kotlinVersion"
     testImplementation 'org.apache.commons:commons-io:1.3.2'
     testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0'
+    testImplementation 'org.robolectric:robolectric:4.3.1'
 }
 
 // Spek (https://spekframework.org/setup-android)
diff --git a/detox/android/detox/src/main/java/com/wix/detox/Detox.java b/detox/android/detox/src/main/java/com/wix/detox/Detox.java
index 03b434a72..568e94cb8 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/Detox.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/Detox.java
@@ -4,14 +4,10 @@ import android.app.Activity;
 import android.app.Instrumentation;
 import android.content.Context;
 import android.content.Intent;
-import android.net.Uri;
 import android.os.Bundle;
 import android.os.Looper;
-import android.util.Base64;
 import android.util.Log;
 
-import java.util.Arrays;
-import java.util.List;
 import java.util.concurrent.TimeUnit;
 
 import androidx.annotation.NonNull;
@@ -74,11 +70,11 @@ import androidx.test.rule.ActivityTestRule;
  */
 public final class Detox {
     private static final String LOG_TAG = "Detox";
-    private static final String LAUNCH_ARGS_KEY = "launchArgs";
-    private static final String DETOX_URL_OVERRIDE_ARG = "detoxURLOverride";
+    private static final String INTENT_LAUNCH_ARGS_KEY = "launchArgs";
     private static final long ACTIVITY_LAUNCH_TIMEOUT = 10000L;
-    private static final List<String> RESERVED_INSTRUMENTATION_ARGS = Arrays.asList("class", "package", "func", "unit", "size", "perf", "debug", "log", "emma", "coverageFile");
 
+    private static final LaunchArgs sLaunchArgs = new LaunchArgs();
+    private static final LaunchIntentsFactory sIntentsFactory = new LaunchIntentsFactory();
     private static ActivityTestRule sActivityTestRule;
 
     /**
@@ -91,6 +87,11 @@ public final class Detox {
         public Integer masterTimeoutSec = 120;
         /** Directly binds to {@link IdlingPolicies#setIdlingResourceTimeout(long, TimeUnit)}. Applied in seconds. */
         public Integer idleResourceTimeoutSec = 60;
+
+        void apply() {
+            IdlingPolicies.setMasterPolicyTimeout(masterTimeoutSec, TimeUnit.SECONDS);
+            IdlingPolicies.setIdlingResourceTimeout(idleResourceTimeoutSec, TimeUnit.SECONDS);
+        }
     }
 
     private Detox() {
@@ -153,7 +154,7 @@ public final class Detox {
      *                         the {@link IdlingPolicies} API.
      */
     public static void runTests(ActivityTestRule activityTestRule, @NonNull final Context context, DetoxIdlePolicyConfig idlePolicyConfig) {
-        applyIdlePolicyConfig(idlePolicyConfig);
+        idlePolicyConfig.apply();
 
         sActivityTestRule = activityTestRule;
 
@@ -186,72 +187,25 @@ public final class Detox {
 
     public static void launchMainActivity() {
         final Activity activity = sActivityTestRule.getActivity();
-        final Context appContext = activity.getApplicationContext();
-        final Intent intent = new Intent(appContext, activity.getClass());
-
-        // CLEAR_TOP is important so as to avoid launching the app's main activity over an existing instance (in the same task),
-        // in case it's already running. It *would* happen without the flag, since by default ActivityTestRule instances
-        // are created so as to force the FLAG_ACTIVITY_NEW_TASK flag in the initial launch (see flags-less c'tor), which
-        // evidently causes consequent launches to create additional activity instances on top of it (although inside the same task).
-        // SINGLE_TOP here is needed as well so as to avoid the *relaunch* of the already-running activity (rather, the intent
-        // would be delivered to that activity's onNewIntent(), as explain in the docs for CLEAR_TOP).
-        intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
-        launchActivitySync(intent);
+        launchActivitySync(sIntentsFactory.activityLaunchIntent(activity));
     }
 
     public static void startActivityFromUrl(String url) {
-        launchActivitySync(intentWithUrl(url, false));
-    }
-
-    private static void applyIdlePolicyConfig(DetoxIdlePolicyConfig idlePolicyConfig) {
-        IdlingPolicies.setMasterPolicyTimeout(idlePolicyConfig.masterTimeoutSec, TimeUnit.SECONDS);
-        IdlingPolicies.setIdlingResourceTimeout(idlePolicyConfig.idleResourceTimeoutSec, TimeUnit.SECONDS);
+        launchActivitySync(sIntentsFactory.intentWithUrl(url, false));
     }
 
     private static Intent extractInitialIntent() {
         Intent intent;
 
-        final String detoxURLOverride = InstrumentationRegistry.getArguments().getString(DETOX_URL_OVERRIDE_ARG);
-        if (detoxURLOverride != null) {
-            intent = intentWithUrl(detoxURLOverride, true);
+        if (sLaunchArgs.hasUrlOverride()) {
+            intent = sIntentsFactory.intentWithUrl(sLaunchArgs.getUrlOverride(), true);
+        } else if (sLaunchArgs.hasNotificationPath()) {
+            Bundle notificationData = new NotificationDataParser(sLaunchArgs.getNotificationPath()).parseNotificationData();
+            intent = sIntentsFactory.intentWithNotificationData(notificationData, true);
         } else {
-            intent = cleanIntent();
-        }
-        intent.putExtra(LAUNCH_ARGS_KEY, readLaunchArgs());
-        return intent;
-    }
-
-    /**
-     * Constructs a near-empty intent, assuming the activityTestRule will fill in all the missing details - namely the activity
-     * class (aka component), which is taken from activityTestRule's own activityClass data member which was set in the c'tor
-     * by the user (outside of Detox)
-     *
-     * @return The resulting intent.
-     */
-    private static Intent cleanIntent() {
-        return new Intent(Intent.ACTION_MAIN);
-    }
-
-    /**
-     * Constructs an intent with a URL such that the resolved activity to be launched would be an activity that has
-     * been defined to match it using an intent-filter xml tag, and has been associated with an {@link Intent#ACTION_VIEW} action.
-     * @param url The URL to associated.
-     *
-     * @return The resulting intent.
-     */
-    private static Intent intentWithUrl(String url, boolean initialLaunch) {
-        final Intent intent = new Intent(Intent.ACTION_VIEW);
-        intent.setData(Uri.parse(url));
-
-        // CLEAR_TOP+SINGLE_TOP is needed here for the same reasons as in launchMainActivity().
-        intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
-
-        // Upon initial launch (first-ever instance of the test activity), we also manually need to add the NEW_TASK flag
-        // so as to mimic the ActivityTestRule's behavior: we get NEW_TASK from it if no flags are specified; here we _do_
-        // specify flags so need to add it ourselves.
-        if (initialLaunch) {
-            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+            intent = sIntentsFactory.cleanIntent();
         }
+        intent.putExtra(INTENT_LAUNCH_ARGS_KEY, sLaunchArgs.asIntentBundle());
         return intent;
     }
 
@@ -277,27 +231,4 @@ public final class Detox {
         instrumentation.addMonitor(activityMonitor);
         instrumentation.waitForMonitorWithTimeout(activityMonitor, ACTIVITY_LAUNCH_TIMEOUT);
     }
-
-    private static Bundle readLaunchArgs() {
-        final Bundle instrumentationArgs = InstrumentationRegistry.getArguments();
-        final Bundle launchArgs = new Bundle();
-
-        for (String arg : instrumentationArgs.keySet()) {
-            if (!RESERVED_INSTRUMENTATION_ARGS.contains(arg)) {
-                launchArgs.putString(arg, decodeLaunchArgValue(arg, instrumentationArgs));
-            }
-        }
-        return launchArgs;
-    }
-
-    private static String decodeLaunchArgValue(String arg, Bundle instrumArgs) {
-        final String rawValue = instrumArgs.getString(arg);
-
-        if (arg.startsWith("detox")) {
-            return rawValue;
-        }
-
-        byte[] base64Value = Base64.decode(rawValue, Base64.DEFAULT);
-        return new String(base64Value);
-    }
 }
diff --git a/detox/android/detox/src/main/java/com/wix/detox/LaunchArgs.java b/detox/android/detox/src/main/java/com/wix/detox/LaunchArgs.java
new file mode 100644
index 000000000..d2c9328f0
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/LaunchArgs.java
@@ -0,0 +1,54 @@
+package com.wix.detox;
+
+import android.os.Bundle;
+import android.util.Base64;
+
+import java.util.Arrays;
+import java.util.List;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+class LaunchArgs {
+    private static final String DETOX_NOTIFICATION_PATH_ARG = "detoxUserNotificationDataURL";
+    private static final String DETOX_URL_OVERRIDE_ARG = "detoxURLOverride";
+    private static final List<String> RESERVED_INSTRUMENTATION_ARGS = Arrays.asList("class", "package", "func", "unit", "size", "perf", "debug", "log", "emma", "coverageFile");
+
+    boolean hasNotificationPath() {
+        return InstrumentationRegistry.getArguments().containsKey(DETOX_NOTIFICATION_PATH_ARG);
+    }
+
+    String getNotificationPath() {
+        return InstrumentationRegistry.getArguments().getString(DETOX_NOTIFICATION_PATH_ARG);
+    }
+
+    boolean hasUrlOverride() {
+        return InstrumentationRegistry.getArguments().containsKey(DETOX_URL_OVERRIDE_ARG);
+    }
+
+    String getUrlOverride() {
+        return InstrumentationRegistry.getArguments().getString(DETOX_URL_OVERRIDE_ARG);
+    }
+
+    Bundle asIntentBundle() {
+        final Bundle instrumentationArgs = InstrumentationRegistry.getArguments();
+        final Bundle launchArgs = new Bundle();
+
+        for (String arg : instrumentationArgs.keySet()) {
+            if (!RESERVED_INSTRUMENTATION_ARGS.contains(arg)) {
+                launchArgs.putString(arg, decodeLaunchArgValue(arg, instrumentationArgs));
+            }
+        }
+        return launchArgs;
+    }
+
+    private String decodeLaunchArgValue(String arg, Bundle instrumArgs) {
+        final String rawValue = instrumArgs.getString(arg);
+
+        if (arg.startsWith("detox")) {
+            return rawValue;
+        }
+
+        byte[] base64Value = Base64.decode(rawValue, Base64.DEFAULT);
+        return new String(base64Value);
+    }
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/LaunchIntentsFactory.kt b/detox/android/detox/src/main/java/com/wix/detox/LaunchIntentsFactory.kt
new file mode 100644
index 000000000..f3fa0e150
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/LaunchIntentsFactory.kt
@@ -0,0 +1,102 @@
+package com.wix.detox
+
+import android.app.Activity
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+
+internal class LaunchIntentsFactory {
+
+    /**
+     * Constructs an intent tightly associated with a specific activity.
+     *
+     * @param activity The activity to launch (typically extracted from an [androidx.test.rule.ActivityTestRule]).
+     *
+     * @return The resulting intent.
+     */
+    fun activityLaunchIntent(activity: Activity)
+        = Intent(activity.applicationContext,
+                 activity.javaClass).apply {
+            flags = coreFlags
+        }
+
+    /**
+     * Constructs a near-empty, activity-anonymous intent, assuming an ActivityTestRule instance that would handle it will fill in all the missing details -
+     * namely, the activity class (aka component), which is taken from activityTestRule's own activityClass data member
+     * which was set in the c'tor by the user (outside of Detox)
+     *
+     * @return The resulting intent.
+     */
+    fun cleanIntent()
+        = Intent(Intent.ACTION_MAIN)
+
+    /**
+     * Constructs an activity-anonymous intent with a URL such that the resolved activity to be launched would be an activity that has
+     * been defined to match it using an intent-filter xml tag associated with an [Intent.ACTION_VIEW] action.
+     *
+     * @param url The activity-lookup URL.
+     * @param initialLaunch Whether this is done in the context of the preliminary app launch, or midway through a running test.
+     *
+     * @return The resulting intent.
+     */
+    fun intentWithUrl(url: String?, initialLaunch: Boolean)
+        = Intent(Intent.ACTION_VIEW).apply {
+            data = Uri.parse(url)
+            flags = coreFlags
+            if (initialLaunch) {
+                addFlags(initialLaunchFlags)
+            }
+        }
+
+    /**
+     * Constructs an activity-anonymous intent with extras equivalent to a given data bundle, assumed
+     * to be holding notification data.
+     *
+     * In essence, this mimics the way the FCM's default implementation handles simple *message*-ish notifications (as
+     * oppose to *data*-ish notifications) on the device, where the sender simply provides only a title, some content
+     * (body), and a flat key-value dictionary. What the FCM service does in the use case is to post a notification
+     * with a simple launcher-like intent, holding the user data in the extra's root, as originally provided.
+     * >Note: this is typically what happens only when the app is in the background/terminated; otherwise the notification
+     * is delivered to the apps' registered handlers.
+     *
+     * Obviously, to properly expose more of what Android has to offer in this context, a more customizable version of
+     * this (e.g. with more explicit intent-related configurations) should eventually be introduced as well.
+     *
+     * @param data The notification data, as a bundle.
+     * @param initialLaunch Whether this is done in the context of the preliminary app launch, or midway through a running test.
+     *
+     * @return The resulting intent.
+     */
+    fun intentWithNotificationData(data: Bundle, initialLaunch: Boolean)
+        = Intent(Intent.ACTION_MAIN).apply {
+            addCategory(Intent.CATEGORY_LAUNCHER)
+            putExtras(data)
+            flags = coreFlags
+            if (initialLaunch) {
+                addFlags(initialLaunchFlags)
+            }
+        }
+
+    /**
+     * The core flags we typically set in all intents:
+     *
+     * CLEAR_TOP is important so as to avoid launching the app's main activity over an existing instance (in the same task),
+     * in case it's already running. It *would* happen without the flag, since by default ActivityTestRule instances
+     * are created so as to force the FLAG_ACTIVITY_NEW_TASK flag in the initial launch (see flags-less c'tor), which
+     * evidently causes consequent launches to create additional activity instances on top of it (although inside the same task).
+     * SINGLE_TOP here is needed as well so as to avoid the *relaunch* of the already-running activity (rather, the intent
+     * would be delivered to that activity's onNewIntent(), as explain in the docs for CLEAR_TOP).
+     */
+    private val coreFlags: Int
+        get() = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
+
+    /**
+     * Additional flags to user to initial-launches (i.e. when launch using a test-rule):
+     *
+     * Upon initial launch (first-ever instance of the test activity), we also manually need to add the NEW_TASK flag
+     * so as to mimic the ActivityTestRule's behavior: we get NEW_TASK from it if no flags are specified; here we _do_
+     * specify flags so need to add it ourselves.
+     */
+    private val initialLaunchFlags: Int
+        get() = Intent.FLAG_ACTIVITY_NEW_TASK
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/NotificationDataParser.kt b/detox/android/detox/src/main/java/com/wix/detox/NotificationDataParser.kt
new file mode 100644
index 000000000..13bce8c6b
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/NotificationDataParser.kt
@@ -0,0 +1,18 @@
+package com.wix.detox
+
+import android.os.Bundle
+import com.wix.detox.common.JsonConverter
+import com.wix.detox.common.TextFileReader
+import org.json.JSONObject
+
+internal class NotificationDataParser(private val notificationPath: String) {
+    fun parseNotificationData(): Bundle {
+        val rawData = readNotificationData()
+        val json = JSONObject(rawData)
+        val payload = json.getJSONObject("payload")
+        return JsonConverter(payload).toBundle()
+    }
+
+    private fun readNotificationData()
+            = TextFileReader(notificationPath).read()
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/common/JsonConverter.kt b/detox/android/detox/src/main/java/com/wix/detox/common/JsonConverter.kt
new file mode 100644
index 000000000..d9e76820a
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/common/JsonConverter.kt
@@ -0,0 +1,41 @@
+package com.wix.detox.common
+
+import android.os.Bundle
+import org.json.JSONArray
+import org.json.JSONObject
+
+class JsonConverter(private val json: JSONObject) {
+    fun toBundle(): Bundle {
+        val bundle = Bundle()
+        json.keys().forEach { key: String ->
+            when (val value = json.get(key)) {
+                is Boolean -> bundle.putBoolean(key, value)
+                is Integer -> bundle.putInt(key, value as Int)
+                is java.lang.Long -> bundle.putLong(key, value as Long)
+                is java.lang.Double -> bundle.putDouble(key, value as Double)
+                is String -> bundle.putString(key, value)
+                is JSONObject -> {
+                    val subObject = json.getJSONObject(key)
+                    val subBundle = JsonConverter(subObject).toBundle()
+                    bundle.putBundle(key, subBundle)
+                }
+                is JSONArray -> {
+                    val stringArray = parseJsonArrayAsStringsList(value)
+                    bundle.putStringArrayList(key, stringArray)
+                }
+            }
+        }
+        return bundle
+    }
+
+    private fun parseJsonArrayAsStringsList(array: JSONArray)
+            = ArrayList<String>(array.length()).apply {
+                for (i in 0 until array.length()) {
+                    val item = array[i]
+                    if (item !is String && item !is Number && item !is Boolean) {
+                        throw IllegalArgumentException("Non-string arrays not currently supported inside JSON's (failed to parse value: $item)")
+                    }
+                    add(item.toString())
+                }
+            }
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/common/TextFileReader.kt b/detox/android/detox/src/main/java/com/wix/detox/common/TextFileReader.kt
new file mode 100644
index 000000000..34c8bbbe6
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/common/TextFileReader.kt
@@ -0,0 +1,7 @@
+package com.wix.detox.common
+
+import java.io.File
+
+class TextFileReader(private val fileName: String) {
+    fun read() = File(fileName).inputStream().readBytes().toString(Charsets.UTF_8)
+}
diff --git a/detox/package.json b/detox/package.json
index 699beeb41..44f532604 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -1,7 +1,7 @@
 {
   "name": "detox",
   "description": "E2E tests and automation for mobile",
-  "version": "16.8.1",
+  "version": "16.8.2",
   "bin": {
     "detox": "local-cli/cli.js"
   },
@@ -38,7 +38,6 @@
     "wtfnode": "^0.8.0"
   },
   "dependencies": {
-    "@babel/core": "^7.4.5",
     "bunyan": "^1.8.12",
     "bunyan-debug-stream": "^1.1.0",
     "chalk": "^2.4.2",
diff --git a/detox/runners/jest-circus/environment.js b/detox/runners/jest-circus/environment.js
index 8daa0840f..90f079ac6 100644
--- a/detox/runners/jest-circus/environment.js
+++ b/detox/runners/jest-circus/environment.js
@@ -35,9 +35,18 @@ class DetoxCircusEnvironment extends NodeEnvironment {
   }
 
   get detox() {
-    return require('../../src')
-      ._setGlobal(this.global)
-      ._suppressLoggingInitErrors();
+    if (!this.global.detox) {
+      // NOTE: require() is deferred on purpose,
+      // to enable calculating line hit coverage
+      // while running internal E2E tests in Detox.
+      // Ideally, it can happen in setup() method.
+
+      this.global.detox = require('../../src')
+        ._setGlobal(this.global)
+        ._suppressLoggingInitErrors();
+    }
+
+    return this.global.detox;
   }
 
   async handleTestEvent(event, state) {
diff --git a/detox/src/android/espressoapi/Detox.js b/detox/src/android/espressoapi/Detox.js
index fd9ffa7ff..ef7675e07 100644
--- a/detox/src/android/espressoapi/Detox.js
+++ b/detox/src/android/espressoapi/Detox.js
@@ -57,44 +57,6 @@ class Detox {
     };
   }
 
-  static cleanIntent() {
-    return {
-      target: {
-        type: "Class",
-        value: "com.wix.detox.Detox"
-      },
-      method: "cleanIntent",
-      args: []
-    };
-  }
-
-  static intentWithUrl(url, initialLaunch) {
-    if (typeof url !== "string") throw new Error("url should be a string, but got " + (url + (" (" + (typeof url + ")"))));
-    if (typeof initialLaunch !== "boolean") throw new Error("initialLaunch should be a boolean, but got " + (initialLaunch + (" (" + (typeof initialLaunch + ")"))));
-    return {
-      target: {
-        type: "Class",
-        value: "com.wix.detox.Detox"
-      },
-      method: "intentWithUrl",
-      args: [url, {
-        type: "boolean",
-        value: initialLaunch
-      }]
-    };
-  }
-
-  static readLaunchArgs() {
-    return {
-      target: {
-        type: "Class",
-        value: "com.wix.detox.Detox"
-      },
-      method: "readLaunchArgs",
-      args: []
-    };
-  }
-
 }
 
 module.exports = Detox;
\ No newline at end of file
diff --git a/detox/src/devices/drivers/DeviceDriverBase.js b/detox/src/devices/drivers/DeviceDriverBase.js
index 270e178ed..62e4b599e 100644
--- a/detox/src/devices/drivers/DeviceDriverBase.js
+++ b/detox/src/devices/drivers/DeviceDriverBase.js
@@ -108,7 +108,9 @@ class DeviceDriverBase {
   }
 
   createPayloadFile(notification) {
-
+    const notificationFilePath = path.join(this.createRandomDirectory(), `payload.json`);
+    fs.writeFileSync(notificationFilePath, JSON.stringify(notification, null, 2));
+    return notificationFilePath;
   }
 
   async setPermissions(deviceId, bundleId, permissions) {
diff --git a/detox/src/devices/drivers/android/AndroidDriver.js b/detox/src/devices/drivers/android/AndroidDriver.js
index d7b0b7f96..abb5b4f1a 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.js
@@ -9,6 +9,7 @@ const InvocationManager = invoke.InvocationManager;
 const ADB = require('./tools/ADB');
 const AAPT = require('./tools/AAPT');
 const APKPath = require('./tools/APKPath');
+const TempFileXfer = require('./tools/TempFileXfer');
 const AppUninstallHelper = require('./tools/AppUninstallHelper');
 const DeviceDriverBase = require('../DeviceDriverBase');
 const DetoxApi = require('../../../android/espressoapi/Detox');
@@ -47,6 +48,7 @@ class AndroidDriver extends DeviceDriverBase {
 
     this.adb = new ADB();
     this.aapt = new AAPT();
+    this.fileXfer = new TempFileXfer(this.adb);
     this.devicePathBuilder = new AndroidDevicePathBuilder();
 
     this.pendingUrl = undefined;
@@ -87,6 +89,16 @@ class AndroidDriver extends DeviceDriverBase {
   async launchApp(deviceId, bundleId, launchArgs, languageAndLocale) {
     await this.emitter.emit('beforeLaunchApp', { deviceId, bundleId, launchArgs });
 
+    if (launchArgs.detoxUserNotificationDataURL) {
+      await this.fileXfer.prepareDestinationDir(deviceId);
+      const destNotificationDataFile = await this.fileXfer.send(deviceId, launchArgs.detoxUserNotificationDataURL, 'notification.json');
+
+      launchArgs = {
+        ...launchArgs,
+        detoxUserNotificationDataURL: destNotificationDataFile,
+      }
+    }
+
     if (!this._isInstrumentationRunning()) {
       await this._launchInstrumentationProcess(deviceId, bundleId, launchArgs);
       await sleep(500);
diff --git a/detox/src/devices/drivers/android/EmulatorDriver.js b/detox/src/devices/drivers/android/EmulatorDriver.js
index f44aa169d..d261231da 100644
--- a/detox/src/devices/drivers/android/EmulatorDriver.js
+++ b/detox/src/devices/drivers/android/EmulatorDriver.js
@@ -74,7 +74,7 @@ class EmulatorDriver extends AndroidDriver {
       testBinaryPath,
     } = this._getInstallPaths(_binaryPath, _testBinaryPath);
 
-    const installHelper = new AppInstallHelper(this.adb);
+    const installHelper = new AppInstallHelper(this.adb, this.fileXfer);
     await installHelper.install(deviceId, binaryPath, testBinaryPath);
   }
 
diff --git a/detox/src/devices/drivers/android/tools/AppInstallHelper.js b/detox/src/devices/drivers/android/tools/AppInstallHelper.js
index 5e97e1788..c702a2d04 100644
--- a/detox/src/devices/drivers/android/tools/AppInstallHelper.js
+++ b/detox/src/devices/drivers/android/tools/AppInstallHelper.js
@@ -1,35 +1,26 @@
-const EMU_TEMP_PATH = '/data/local/tmp';
-const EMU_TEMP_INSTALL_PATH = `${EMU_TEMP_PATH}/detox`;
-
 class AppInstallHelper {
-  constructor(adb, tempDir = EMU_TEMP_INSTALL_PATH) {
+  constructor(adb, fileXfer) {
     this._adb = adb;
-    this._tempDir = tempDir;
+    this._fileXfer = fileXfer;
   }
 
   async install(deviceId, appBinaryPath, testBinaryPath) {
-    await this._prepareTempDirOnDevice(deviceId);
+    await this._fileXfer.prepareDestinationDir(deviceId);
 
-    const appBinaryPathOnTarget = this._getTargetBinaryPath(false);
+    const appBinaryPathOnTarget = this._getTargetBinaryName(false);
     await this._pushAndInstallBinary(deviceId, appBinaryPath, appBinaryPathOnTarget);
 
-    const testBinaryPathOnTarget = this._getTargetBinaryPath(true);
+    const testBinaryPathOnTarget = this._getTargetBinaryName(true);
     await this._pushAndInstallBinary(deviceId, testBinaryPath, testBinaryPathOnTarget);
   }
 
-  async _prepareTempDirOnDevice(deviceId) {
-    await this._adb.shell(deviceId, `rm -fr ${this._tempDir}`);
-    await this._adb.shell(deviceId, `mkdir -p ${this._tempDir}`);
-  }
-
-  async _pushAndInstallBinary(deviceId, binaryPath, binaryPathOnTarget) {
-    await this._adb.push(deviceId, binaryPath, binaryPathOnTarget);
+  async _pushAndInstallBinary(deviceId, binaryPath, binaryFilenameOnTarget) {
+    const binaryPathOnTarget = await this._fileXfer.send(deviceId, binaryPath, binaryFilenameOnTarget);
     await this._adb.remoteInstall(deviceId, binaryPathOnTarget);
   }
 
-  _getTargetBinaryPath(isTestBinary) {
-    const filename = isTestBinary ? 'Test.apk' : 'Application.apk';
-    return `${this._tempDir}/${filename}`;
+  _getTargetBinaryName(isTestBinary) {
+    return isTestBinary ? 'Test.apk' : 'Application.apk';
   }
 }
 
diff --git a/detox/src/devices/drivers/android/tools/FileXfer.js b/detox/src/devices/drivers/android/tools/FileXfer.js
new file mode 100644
index 000000000..b9b7e25c5
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/FileXfer.js
@@ -0,0 +1,21 @@
+const path = require('path');
+
+class FileXfer {
+  constructor(adb, destinationDir) {
+    this._adb = adb;
+    this._dir = destinationDir;
+  }
+
+  async prepareDestinationDir(deviceId) {
+    await this._adb.shell(deviceId, `rm -fr ${this._dir}`);
+    await this._adb.shell(deviceId, `mkdir -p ${this._dir}`);
+  }
+
+  async send(deviceId, sourcePath, destinationFilename) {
+    const destinationPath = path.join(this._dir, destinationFilename);
+    await this._adb.push(deviceId, sourcePath, destinationPath);
+    return destinationPath;
+  }
+}
+
+module.exports = FileXfer;
diff --git a/detox/src/devices/drivers/android/tools/TempFileXfer.js b/detox/src/devices/drivers/android/tools/TempFileXfer.js
new file mode 100644
index 000000000..f61047372
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/TempFileXfer.js
@@ -0,0 +1,12 @@
+const FileXfer = require('./FileXfer');
+
+const EMU_TEMP_PATH = '/data/local/tmp';
+const EMU_TEMP_INSTALL_PATH = `${EMU_TEMP_PATH}/detox`;
+
+class TempFileXfer extends FileXfer {
+  constructor(adb) {
+    super(adb, EMU_TEMP_INSTALL_PATH);
+  }
+}
+
+module.exports = TempFileXfer;
diff --git a/detox/src/devices/drivers/ios/IosDriver.js b/detox/src/devices/drivers/ios/IosDriver.js
index d23572005..cea3eb5ec 100644
--- a/detox/src/devices/drivers/ios/IosDriver.js
+++ b/detox/src/devices/drivers/ios/IosDriver.js
@@ -37,12 +37,6 @@ class IosDriver extends DeviceDriverBase {
     };
   }
 
-  createPayloadFile(notification) {
-    const notificationFilePath = path.join(this.createRandomDirectory(), `payload.json`);
-    fs.writeFileSync(notificationFilePath, JSON.stringify(notification, null, 2));
-    return notificationFilePath;
-  }
-
   async setURLBlacklist(urlList) {
     await this.client.execute(
       GREYConfigurationApi.setValueForConfigKey(
diff --git a/examples/demo-native-android/package.json b/examples/demo-native-android/package.json
index c56295176..b1cffa25a 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": "16.8.1",
+  "version": "16.8.2",
   "private": true,
   "scripts": {
     "packager": "react-native start",
@@ -8,7 +8,7 @@
     "e2e": "mocha e2e --opts ./e2e/mocha.opts"
   },
   "devDependencies": {
-    "detox": "^16.8.1",
+    "detox": "^16.8.2",
     "mocha": "^6.1.3"
   },
   "detox": {}
diff --git a/examples/demo-native-ios/package.json b/examples/demo-native-ios/package.json
index 144204dd0..ccff8585b 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": "16.8.1",
+  "version": "16.8.2",
   "private": true,
   "devDependencies": {
-    "detox": "^16.8.1",
+    "detox": "^16.8.2",
     "mocha": "^6.1.3"
   },
   "detox": {
diff --git a/examples/demo-plugin/package.json b/examples/demo-plugin/package.json
index 0cb4b515a..32db2f7ea 100644
--- a/examples/demo-plugin/package.json
+++ b/examples/demo-plugin/package.json
@@ -1,12 +1,12 @@
 {
   "name": "demo-plugin",
-  "version": "16.8.1",
+  "version": "16.8.2",
   "private": true,
   "scripts": {
     "test:plugin": "detox test --configuration plugin -l verbose"
   },
   "devDependencies": {
-    "detox": "^16.8.1",
+    "detox": "^16.8.2",
     "jest": "26.x.x"
   },
   "detox": {
diff --git a/examples/demo-react-native-detox-instruments/package.json b/examples/demo-react-native-detox-instruments/package.json
index c8f66954a..a4e9e0a4b 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": "16.8.1",
+  "version": "16.8.2",
   "private": true,
   "scripts": {},
   "devDependencies": {
-    "detox": "^16.8.1",
+    "detox": "^16.8.2",
     "mocha": "6.x.x"
   },
   "detox": {
diff --git a/examples/demo-react-native-jest/.detoxrc.json b/examples/demo-react-native-jest/detox.config.js
similarity index 66%
rename from examples/demo-react-native-jest/.detoxrc.json
rename to examples/demo-react-native-jest/detox.config.js
index 60f9eaaa7..fbe6cb33f 100644
--- a/examples/demo-react-native-jest/.detoxrc.json
+++ b/examples/demo-react-native-jest/detox.config.js
@@ -1,6 +1,12 @@
-{
+module.exports = {
   "testRunner": "jest",
-  "runnerConfig": "e2e/config.json",
+  "runnerConfig": process.env.DETOX_EXPOSE_GLOBALS === '0' ? 'e2eExplicitRequire/config.json' : 'e2e/config.json',
+  "specs": process.env.DETOX_EXPOSE_GLOBALS === '0' ? 'e2eExplicitRequire' : 'e2e',
+  "behavior": {
+    "init": {
+      "exposeGlobals": process.env.DETOX_EXPOSE_GLOBALS === '0' ? false : true,
+    },
+  },
   "configurations": {
     "ios.sim.release": {
       "type": "ios.simulator",
@@ -23,4 +29,4 @@
       }
     }
   }
-}
+};
diff --git a/examples/demo-react-native-jest/package.json b/examples/demo-react-native-jest/package.json
index dcdc8a971..f2c150a73 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": "16.8.1",
+  "version": "16.8.2",
   "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": "^16.8.1",
+    "detox": "^16.8.2",
     "jest": "26.x.x",
     "jest-circus": "26.x.x",
     "sanitize-filename": "^1.6.1"
diff --git a/examples/demo-react-native/detox.config.js b/examples/demo-react-native/detox.config.js
index c1f803a5a..67737e07f 100644
--- a/examples/demo-react-native/detox.config.js
+++ b/examples/demo-react-native/detox.config.js
@@ -1,6 +1,7 @@
 module.exports = {
   "test-runner": "mocha",
   "runner-config": "e2e/.mocharc.json",
+  "specs": process.env.DETOX_EXPOSE_GLOBALS === '0' ? 'e2eExplicitRequire' : 'e2e',
   "behavior": {
     "init": {
       "exposeGlobals": process.env.DETOX_EXPOSE_GLOBALS === '0' ? false : true,
diff --git a/examples/demo-react-native/package.json b/examples/demo-react-native/package.json
index 0f570a859..a8fd4d1ac 100644
--- a/examples/demo-react-native/package.json
+++ b/examples/demo-react-native/package.json
@@ -1,6 +1,6 @@
 {
   "name": "example",
-  "version": "16.8.1",
+  "version": "16.8.2",
   "private": true,
   "scripts": {
     "start": "react-native start",
@@ -12,8 +12,6 @@
     "test:android-debug": "detox test --configuration android.emu.debug",
     "test:android-release": "detox test --configuration android.emu.release",
     "test:android-release-ci": "detox test --configuration android.emu.release -l verbose --headless --record-logs all --take-screenshots all",
-    "test:android-explicit-require": "DETOX_EXPOSE_GLOBALS=0 detox test e2eExplicitRequire -c android.emu.release",
-    "test:android-explicit-require-ci": "DETOX_EXPOSE_GLOBALS=0 detox test e2eExplicitRequire -c android.emu.release -l verbose --headless --record-logs all --take-screenshots all",
     "e2e:ios": "npm run build:ios && npm run test:ios",
     "e2e:android-debug": "npm run build:android-debug && npm run test:android-debug",
     "e2e:android-release": "npm run build:android-release && npm run test:android-release"
@@ -23,7 +21,7 @@
     "react-native": "0.62.x"
   },
   "devDependencies": {
-    "detox": "^16.8.1",
+    "detox": "^16.8.2",
     "mocha": "^6.1.3"
   }
 }
diff --git a/lerna.json b/lerna.json
index f1ce353cf..aad05bd19 100644
--- a/lerna.json
+++ b/lerna.json
@@ -13,7 +13,7 @@
     "generation",
     "."
   ],
-  "version": "16.8.1",
+  "version": "16.8.2",
   "npmClient": "npm",
   "command": {
     "publish": {
diff --git a/package.json b/package.json
index c4c2c4d0b..5b306716f 100644
--- a/package.json
+++ b/package.json
@@ -18,5 +18,5 @@
     "semver": "5.x.x",
     "shell-utils": "1.x.x"
   },
-  "version": "16.8.1"
+  "version": "16.8.2"
 }
diff --git a/scripts/demo-projects.android.sh b/scripts/demo-projects.android.sh
index 3807d1a1c..06812987a 100755
--- a/scripts/demo-projects.android.sh
+++ b/scripts/demo-projects.android.sh
@@ -17,11 +17,12 @@ popd
 # as it runs tests in parallel.
 pushd examples/demo-react-native-jest
 run_f "npm run test:android-release-ci"
+DETOX_EXPOSE_GLOBALS=0 run_f "npm run test:android-release-ci"
 popd
 
 pushd examples/demo-react-native
 run_f "npm run test:android-release-ci"
-run_f "npm run test:android-explicit-require-ci"
+DETOX_EXPOSE_GLOBALS=0 run_f "npm run test:android-release-ci"
 popd
 
 pushd examples/demo-plugin
diff --git a/scripts/demo-projects.ios.sh b/scripts/demo-projects.ios.sh
index 28b08ce08..89ad9f944 100755
--- a/scripts/demo-projects.ios.sh
+++ b/scripts/demo-projects.ios.sh
@@ -11,11 +11,12 @@ HOMEBREW_NO_AUTO_UPDATE=1 brew cask reinstall detox-instruments
 pushd examples/demo-react-native
 run_f "detox build -c ios.sim.release"
 run_f "detox test -c ios.sim.release"
-DETOX_EXPOSE_GLOBALS=0 run_f "detox test -c ios.sim.release e2eExplicitRequire"
+DETOX_EXPOSE_GLOBALS=0 run_f "detox test -c ios.sim.release"
 popd
 
 pushd examples/demo-react-native-jest
 run_f "npm run test:ios-release-ci"
+DETOX_EXPOSE_GLOBALS=0 run_f "npm run test:ios-release-ci"
 popd
 
 pushd examples/demo-react-native-detox-instruments


Test patch:
diff --git a/detox/android/detox/src/test/java/com/wix/detox/common/JsonConverterTest.kt b/detox/android/detox/src/test/java/com/wix/detox/common/JsonConverterTest.kt
new file mode 100644
index 000000000..a4cafd9c2
--- /dev/null
+++ b/detox/android/detox/src/test/java/com/wix/detox/common/JsonConverterTest.kt
@@ -0,0 +1,138 @@
+package com.wix.detox.common
+
+import org.assertj.core.api.Assertions.assertThat
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import java.lang.IllegalArgumentException
+import kotlin.test.assertFailsWith
+
+@RunWith(RobolectricTestRunner::class)
+class JsonConverterTest {
+
+    @Test
+    fun `should convert an empty json to an empty bundle`() {
+        val uut = JsonConverter(JSONObject())
+        assertThat(uut.toBundle().isEmpty).isTrue()
+    }
+
+    @Test
+    fun `should convert a boolean`() {
+        val json = aJsonWithAFlag()
+
+        val uut = JsonConverter(json)
+        assertThat(uut.toBundle().getBoolean("flag")).isEqualTo(true)
+    }
+
+    @Test
+    fun `should convert a string`() {
+        val json = aJsonWithAString()
+        val uut = JsonConverter(json)
+        assertThat(uut.toBundle().getString("test")).isEqualTo("mock")
+    }
+
+    @Test
+    fun `should convert an integer`() {
+        val json = aJsonWithAnInt()
+        val uut = JsonConverter(json)
+        assertThat(uut.toBundle().getInt("integer")).isEqualTo(1234)
+    }
+
+    @Test
+    fun `should convert a long-int`() {
+        val json = aJsonWithALongInt()
+        val uut = JsonConverter(json)
+        assertThat(uut.toBundle().getLong("longint")).isEqualTo(1234L)
+    }
+
+    @Test
+    fun `should convert a double`() {
+        val json = aJsonWithADouble()
+        val uut = JsonConverter(json)
+        assertThat(uut.toBundle().getDouble("double")).isEqualTo(1234.56)
+    }
+
+    @Test
+    fun `should convert a complete child-object`() {
+        val json = aJsonWithAChildObject()
+        val uut = JsonConverter(json)
+
+        val subBundle = uut.toBundle().getBundle("obj")
+        assertThat(subBundle).isNotNull()
+
+        subBundle!!
+        assertThat(subBundle.get("test")).isEqualTo("777")
+        assertThat(subBundle.get("double")).isEqualTo(1234.56)
+        assertThat(subBundle.get("longint")).isEqualTo(2_222_222_222L)
+    }
+
+    @Test
+    fun `should convert a strings array`() {
+        val json = aJsonWithAStringsArray()
+        val uut = JsonConverter(json)
+
+        val array = uut.toBundle().getStringArrayList("arr")
+        assertThat(array).isNotNull()
+
+        array!!
+        assertThat(array).isEqualTo(arrayListOf("rock", "paper", "scissors"))
+    }
+
+    @Test
+    fun `should convert a primitives array`() {
+        val json = aJsonWithPrimitiveArray()
+        val uut = JsonConverter(json)
+
+        val array = uut.toBundle().getStringArrayList("arr")
+        assertThat(array).isNotNull()
+
+        array!!
+        assertThat(array).isEqualTo(arrayListOf("1234", "12.34", "1234", "true"))
+    }
+
+    @Test
+    fun `should fail to convert a complex array`() {
+        val json = aJsonWithComplexArray()
+        val uut = JsonConverter(json)
+
+        assertFailsWith<IllegalArgumentException> {
+            uut.toBundle()
+        }
+    }
+}
+
+private fun aJsonWithAFlag() = JSONObject().put("flag", true)
+private fun aJsonWithAString() = JSONObject().put("test", "mock")
+private fun aJsonWithAnInt() = JSONObject().put("integer", 1234)
+private fun aJsonWithALongInt() = JSONObject().put("longint", 1234L)
+private fun aJsonWithADouble() = JSONObject().put("double", 1234.56)
+private fun aJsonWithAChildObject(): JSONObject {
+    val child = JSONObject("""{
+        "flag": true,
+        "test": "777",
+        "integer": 1234,
+        "longint": 2222222222,
+        "double": 1234.56
+    }""".trimIndent())
+    return JSONObject().put("obj", child)
+}
+private fun aJsonWithAStringsArray() = JSONObject().apply {
+    val array = JSONArray("""[
+        "rock", "paper", "scissors"
+    ]""".trimIndent())
+    put("arr", array)
+}
+private fun aJsonWithPrimitiveArray() = JSONObject().apply {
+    val array = JSONArray("""[
+        1234, 12.34, "1234", true
+    ]""".trimIndent())
+    put("arr", array)
+}
+private fun aJsonWithComplexArray() = JSONObject().apply {
+    val array = JSONArray("""[ 
+        1234, { "sub": "obj" }
+    ]""".trimIndent())
+    put("arr", array)
+}
diff --git a/detox/android/detox/src/test/resources/robolectric.properties b/detox/android/detox/src/test/resources/robolectric.properties
new file mode 100644
index 000000000..932b01b9e
--- /dev/null
+++ b/detox/android/detox/src/test/resources/robolectric.properties
@@ -0,0 +1,1 @@
+sdk=28
diff --git a/detox/src/devices/drivers/android/AndroidDriver.test.js b/detox/src/devices/drivers/android/AndroidDriver.test.js
index da76b5f80..2daa010f9 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.test.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.test.js
@@ -2,6 +2,7 @@ describe('Android driver', () => {
 
   const deviceId = 'device-id-mock';
   const bundleId = 'bundle-id-mock';
+  const mockNotificationDataTargetPath = '/ondevice/path/to/notification.json';
 
   const mockGetAbsoluteBinaryPathImpl = (x) => `absolutePathOf(${x})`;
   const mockAPKPathGetTestApkPathImpl = (x) => `testApkPathOf(${x})`;
@@ -12,6 +13,12 @@ describe('Android driver', () => {
     }
   }
 
+  class MockTempFileXferClass {
+    constructor() {
+      Object.assign(this, fileXfer);
+    }
+  }
+
   class MockAsyncEmitterClass {
     constructor() {
       this.emit = jest.fn();
@@ -32,7 +39,6 @@ describe('Android driver', () => {
   let getAbsoluteBinaryPath;
   let fs;
   let exec;
-  let adb;
   beforeEach(() => {
     jest.mock('fs', () => ({
       existsSync: jest.fn(),
@@ -82,11 +88,12 @@ describe('Android driver', () => {
     };
   });
 
+  let adb;
   beforeEach(() => {
     const ADB = jest.genMockFromModule('./tools/ADB');
     adb = new ADB();
     adb.adbBin = 'ADB binary mock';
-    adb.spawnInstrumentation = jest.fn().mockReturnValue({
+    adb.spawnInstrumentation.mockReturnValue({
       childProcess: {
         on: jest.fn(),
         stdout: {
@@ -98,6 +105,14 @@ describe('Android driver', () => {
     jest.mock('./tools/ADB', () => MockADBClass);
   });
 
+  let fileXfer;
+  beforeEach(() => {
+    const TempFilesXfer = jest.genMockFromModule('./tools/TempFileXfer');
+    fileXfer = new TempFilesXfer();
+    fileXfer.send.mockResolvedValue(mockNotificationDataTargetPath)
+    jest.mock('./tools/TempFileXfer', () => MockTempFileXferClass);
+  });
+
   let uut;
   beforeEach(() => {
     const AndroidDriver = require('./AndroidDriver');
@@ -121,6 +136,43 @@ describe('Android driver', () => {
     });
   });
 
+  describe('Notification data handling (user-notification-data-URL arg)', () => {
+    const launchArgs = Object.freeze({
+      detoxUserNotificationDataURL: '/path/to/notif.data',
+    });
+
+    it('should prepare the device for receiving notification data file', async () => {
+      await uut.launchApp(deviceId, bundleId, launchArgs, '');
+      expect(fileXfer.prepareDestinationDir).toHaveBeenCalledWith(deviceId);
+    });
+
+    it('should transfer the notification data file to the device', async () => {
+      await uut.launchApp(deviceId, bundleId, launchArgs, '');
+      expect(fileXfer.send).toHaveBeenCalledWith(deviceId, launchArgs.detoxUserNotificationDataURL, 'notification.json');
+    });
+
+    it('should not send the data if device prep fails', async () => {
+      fileXfer.prepareDestinationDir.mockRejectedValue(new Error())
+      try {
+        await uut.launchApp(deviceId, bundleId, launchArgs, '');
+        fail('Expected an error');
+      } catch (e) {
+      }
+    });
+
+    it('should launch instrument with a modified notification data URL arg', async () => {
+      fileXfer.send.mockReturnValue(mockNotificationDataTargetPath);
+
+      await uut.launchApp(deviceId, bundleId, launchArgs, '');
+
+      expect(adb.spawnInstrumentation).toHaveBeenCalledWith(
+        expect.anything(),
+        expect.arrayContaining(['detoxUserNotificationDataURL', mockNotificationDataTargetPath]),
+        undefined,
+      );
+    });
+  });
+
   describe('Device ready-wait', () => {
     beforeEach(async () => {
       await uut.launchApp(deviceId, bundleId, {}, '');
diff --git a/detox/src/devices/drivers/android/tools/AppInstallHelper.test.js b/detox/src/devices/drivers/android/tools/AppInstallHelper.test.js
index 2d03374e9..32993b7db 100644
--- a/detox/src/devices/drivers/android/tools/AppInstallHelper.test.js
+++ b/detox/src/devices/drivers/android/tools/AppInstallHelper.test.js
@@ -1,39 +1,32 @@
 const deviceId = 'mock-device-id';
-const deviceTempDir = '/mock-tmp-dir';
 const appBinaryPath = '/mock-app-binary-path/binary.apk';
 const testBinaryPath = '/mock-test-binary-path/test/binary.apk';
 
 describe('Android app installation helper', () => {
   let adb;
-  class MockAdbClass {
-    constructor() {
-      this.shell = (...args) => adb.shell(...args);
-      this.push = (...args) => adb.push(...args);
-      this.remoteInstall = (...args) => adb.remoteInstall(...args);
-    }
-  }
 
   beforeEach(() => {
     const ADBMock = jest.genMockFromModule('./ADB');
     adb = new ADBMock();
-    jest.mock('./ADB', () => MockAdbClass);
   });
 
+  let fileXfer;
   let uut;
   beforeEach(() => {
+    const TempFileXfer = jest.genMockFromModule('./TempFileXfer');
+    fileXfer = new TempFileXfer();
+
     const AppInstallHelper = require('./AppInstallHelper');
-    uut = new AppInstallHelper(adb, deviceTempDir);
+    uut = new AppInstallHelper(adb, fileXfer);
   });
 
-  it('should recreate a temp dir on the device', async () => {
+  it('should recreate the transient dir on the device', async () => {
     await uut.install(deviceId, appBinaryPath, testBinaryPath);
-
-    expect(adb.shell).toHaveBeenCalledWith(deviceId, `rm -fr ${deviceTempDir}`);
-    expect(adb.shell).toHaveBeenCalledWith(deviceId, `mkdir -p ${deviceTempDir}`);
+    expect(fileXfer.prepareDestinationDir).toHaveBeenCalledWith(deviceId);
   });
 
-  it('should throw if shell command fails', async () => {
-    adb.shell.mockRejectedValue(new Error('mocked error in adb-shell'));
+  it('should throw if transient dir prep fails', async () => {
+    fileXfer.prepareDestinationDir.mockRejectedValue(new Error('mocked error in adb-shell'));
 
     try {
       await uut.install(deviceId, appBinaryPath, testBinaryPath);
@@ -41,18 +34,18 @@ describe('Android app installation helper', () => {
     } catch (err) {}
   });
 
-  it('should push app-binary file to temp dir on device', async () => {
+  it('should push app-binary file to the device', async () => {
     await uut.install(deviceId, appBinaryPath, testBinaryPath);
-    expect(adb.push).toHaveBeenCalledWith(deviceId, appBinaryPath, '/mock-tmp-dir/Application.apk');
+    expect(fileXfer.send).toHaveBeenCalledWith(deviceId, appBinaryPath, 'Application.apk');
   });
 
-  it('should push test-binary file to temp dir on device', async () => {
+  it('should push test-binary file to the device', async () => {
     await uut.install(deviceId, appBinaryPath, testBinaryPath);
-    expect(adb.push).toHaveBeenCalledWith(deviceId, testBinaryPath, '/mock-tmp-dir/Test.apk');
+    expect(fileXfer.send).toHaveBeenCalledWith(deviceId, testBinaryPath, 'Test.apk');
   });
 
-  it('should fail if adb-push fails', async () => {
-    adb.push.mockRejectedValue(new Error('mocked error in adb-push'));
+  it('should fail if file push fails', async () => {
+    fileXfer.send.mockRejectedValue(new Error('mocked error in adb-push'));
 
     try {
       await uut.install(deviceId, appBinaryPath, testBinaryPath);
@@ -61,9 +54,13 @@ describe('Android app installation helper', () => {
   });
 
   it('should remote-install both binaries via shell', async () => {
+    fileXfer.send
+      .mockReturnValueOnce('/mocked-final-dir/first.apk')
+      .mockReturnValueOnce('/mocked-final-dir/second.apk');
+
     await uut.install(deviceId, appBinaryPath, testBinaryPath);
-    expect(adb.remoteInstall).toHaveBeenCalledWith(deviceId, '/mock-tmp-dir/Application.apk');
-    expect(adb.remoteInstall).toHaveBeenCalledWith(deviceId, '/mock-tmp-dir/Test.apk');
+    expect(adb.remoteInstall).toHaveBeenCalledWith(deviceId, '/mocked-final-dir/first.apk');
+    expect(adb.remoteInstall).toHaveBeenCalledWith(deviceId, '/mocked-final-dir/second.apk');
   });
 
   it('should fail if remote-install fails', async () => {
@@ -74,13 +71,4 @@ describe('Android app installation helper', () => {
       fail('expected to throw');
     } catch(err) {}
   });
-
-  it('should use a default temp-dir', async () => {
-    const AppInstallHelper = require('./AppInstallHelper');
-    uut = new AppInstallHelper(adb, undefined);
-
-    await uut.install(deviceId, appBinaryPath, testBinaryPath);
-
-    expect(adb.push).toHaveBeenCalledWith(deviceId, appBinaryPath, '/data/local/tmp/detox/Application.apk');
-  });
 });
diff --git a/detox/src/devices/drivers/android/tools/FileXfer.test.js b/detox/src/devices/drivers/android/tools/FileXfer.test.js
new file mode 100644
index 000000000..97fa952de
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/FileXfer.test.js
@@ -0,0 +1,40 @@
+const deviceId = 'mock-device-id';
+const deviceDestinationDir = '/mock-tmp-dir';
+
+describe('File-transfer util', () => {
+  let adb;
+  let uut;
+
+  beforeEach(() => {
+    const ADBMock = jest.genMockFromModule('./ADB');
+    adb = new ADBMock();
+
+    const FileXfer = require('./FileXfer');
+    uut = new FileXfer(adb, deviceDestinationDir);
+  });
+
+  it('should create the destination directory on the device', async () => {
+    await uut.prepareDestinationDir(deviceId);
+
+    expect(adb.shell).toHaveBeenCalledWith(deviceId, `rm -fr ${deviceDestinationDir}`);
+    expect(adb.shell).toHaveBeenCalledWith(deviceId, `mkdir -p ${deviceDestinationDir}`);
+  });
+
+  it('should send a file by path', async () => {
+    const sourcePath = '/source/path/source-file.src';
+    const destFilename = 'dest-file.dst';
+
+    await uut.send(deviceId, sourcePath, destFilename);
+
+    expect(adb.push).toHaveBeenCalledWith(deviceId, sourcePath, '/mock-tmp-dir/dest-file.dst');
+  });
+
+  it('should return final destination path', async () => {
+    const sourcePath = '/source/path/source-file.src';
+    const destFilename = 'dest-file.dst';
+
+    const destPath = await uut.send(deviceId, sourcePath, destFilename);
+
+    expect(destPath).toEqual(`${deviceDestinationDir}/${destFilename}`);
+  });
+});
diff --git a/detox/src/devices/drivers/android/tools/TempFileXfer.test.js b/detox/src/devices/drivers/android/tools/TempFileXfer.test.js
new file mode 100644
index 000000000..3ec9097bf
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/TempFileXfer.test.js
@@ -0,0 +1,17 @@
+describe('Temp file transfer', () => {
+  let adb;
+  let uut;
+  beforeEach(() => {
+    const ADB = jest.genMockFromModule('./ADB');
+    adb = new ADB();
+
+    const TempFileXfer = require('./TempFileXfer');
+    uut = new TempFileXfer(adb);
+  });
+
+  it('should use the default temp-dir', async () => {
+    await uut.prepareDestinationDir('device-id');
+
+    expect(adb.shell).toHaveBeenCalledWith('device-id', `rm -fr /data/local/tmp/detox`);
+  });
+});
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 ba54720bc..0fd3331b0 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
@@ -1,6 +1,7 @@
 package com.example;
 
 import android.app.Activity;
+import android.content.Intent;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.Looper;
@@ -78,20 +79,18 @@ public class NativeModule extends ReactContextBaseJavaModule {
 
     @ReactMethod
     public void getLaunchArguments(Promise promise) {
-        final Activity currentActivity = getCurrentActivity();
-        if (currentActivity != null) {
-            final Bundle launchArgs = currentActivity.getIntent().getBundleExtra("launchArgs");
-            if (launchArgs != null) {
-                final WritableMap launchArgsMap = Arguments.fromBundle(launchArgs);
-                promise.resolve(launchArgsMap);
-                return;
-            }
-        }
-        promise.resolve(Arguments.createMap());
+        promise.resolve(parseIntentExtras("launchArgs"));
+    }
+
+    @ReactMethod
+    public void parseNotificationData(String innerKey, Promise promise) {
+        Bundle data = getIntentExtras(innerKey);
+        data.remove("launchArgs");
+        promise.resolve(Arguments.fromBundle(data));
     }
 
     @ReactMethod
-    public void spyLongTaps(final String testID) {
+    public void spyLongTaps(String testID) {
         new ViewSpies.LongTapCrasher(testID).attach();
     }
 
@@ -113,4 +112,24 @@ public class NativeModule extends ReactContextBaseJavaModule {
             throw new RuntimeException("Simulated crash (native)");
         });
     }
+
+    private WritableMap parseIntentExtras(String bundleKey) {
+        Bundle extras = getIntentExtras(bundleKey);
+        return Arguments.fromBundle(extras);
+    }
+
+    private Bundle getIntentExtras(String bundleKey) {
+        final Activity currentActivity = getCurrentActivity();
+        if (currentActivity == null) {
+            return new Bundle();
+        }
+
+        final Intent intent = currentActivity.getIntent();
+        final Bundle extras = (bundleKey == null ? intent.getExtras() : intent.getBundleExtra(bundleKey));
+        if (extras == null) {
+            return new Bundle();
+        }
+
+        return extras;
+    }
 }
diff --git a/detox/test/e2e/11.user-notifications.test.js b/detox/test/e2e/11.user-notifications.test.js
index 91c5d9887..93014f1d2 100644
--- a/detox/test/e2e/11.user-notifications.test.js
+++ b/detox/test/e2e/11.user-notifications.test.js
@@ -1,3 +1,8 @@
+const {
+  userNotificationPushTrigger,
+  userNotificationCalendarTrigger,
+} = require('./utils/notifications');
+
 describe(':ios: User Notifications', () => {
   it('Init from push notification', async () => {
     await device.launchApp({newInstance: true, userNotification: userNotificationPushTrigger});
@@ -25,8 +30,8 @@ describe(':ios: User Notifications', () => {
 
   it('Foreground push notifications', async () => {
     await device.launchApp({newInstance: true});
-    await device.sendUserNotification(userNotificationCalendarTrigger);
-    await expect(element(by.text('From calendar'))).toBeVisible();
+    await device.sendUserNotification(userNotificationPushTrigger);
+    await expect(element(by.text('From push'))).toBeVisible();
   });
 
   it('Foreground calendar notifications', async () => {
@@ -36,53 +41,30 @@ describe(':ios: User Notifications', () => {
   });
 });
 
-const userNotificationPushTrigger = {
-  "trigger": {
-    "type": "push"
-  },
-  "title": "From push",
-  "subtitle": "Subtitle",
-  "body": "Body",
-  "badge": 1,
-  "payload": {
-    "key1": "value1",
-    "key2": "value2"
-  },
-  "category": "com.example.category",
-  "content-available": 0,
-  "action-identifier": "default"
-};
+describe(':android: User Notifications', () => {
+  async function assertNotificationData(key, expectedValue) {
+    await expect(element(by.id(`notificationData-${key}.name`))).toBeVisible();
+    await expect(element(by.id(`notificationData-${key}.value`))).toHaveText(expectedValue);
+  }
 
-const userNotificationCalendarTrigger = {
-  "trigger": {
-    "type": "calendar",
-    "date-components": {
-      "era": 1,
-      "year": 2017,
-      "month": 1,
-      "day": 1,
-      "hour": 0,
-      "minute": 0,
-      "second": 0,
-      "weekday": 0,
-      "weekdayOrdinal": 0,
-      "quarter": 1,
-      "weekOfMonth": 1,
-      "weekOfYear": 1,
-      "leapMonth": false
-    },
-    "repeats": true
-  },
-  "title": "From calendar",
-  "subtitle": "Subtitle",
-  "body": "From calendar",
-  "badge": 1,
-  "payload": {
-    "key1": "value1",
-    "key2": "value2"
-  },
-  "category": "com.example.category",
-  "user-text": "Hi there!",
-  "content-available": 0,
-  "action-identifier": "default"
-};
+  it('should launch app with extras', async () => {
+    const googleProjectId = 284440699462;
+    const userNotification = {
+      payload: {
+        from: googleProjectId,
+        userData: 'userDataValue',
+        userDataArray: ['rock', 'paper', 'scissors'],
+        'google.sent_time': 1592133826891,
+        'google.ttl': 2419200,
+        'google.original_priority': 'high',
+        'collapse_key': 'com.wix.reactnativenotifications.app',
+      },
+    };
+    await device.launchApp({ newInstance: true, userNotification });
+    await element(by.text('Launch-Notification')).tap();
+    await expect(element(by.text('Launch-notification Data'))).toBeVisible();
+    await assertNotificationData('from', googleProjectId.toString());
+    await assertNotificationData('userData', userNotification.payload.userData);
+    await assertNotificationData('userDataArray', JSON.stringify(userNotification.payload.userDataArray));
+  });
+});
diff --git a/detox/test/e2e/22.launch-args.test.js b/detox/test/e2e/22.launch-args.test.js
index 0c4bfb139..c85f04577 100644
--- a/detox/test/e2e/22.launch-args.test.js
+++ b/detox/test/e2e/22.launch-args.test.js
@@ -2,7 +2,7 @@
 // existing tests that check deep-link URLs. Combined with the fact that we do not yet
 // support complex args on iOS -- no point in testing it out.
 describe(':android: Launch arguments', () => {
-  async function assertLaunchArg(launchArgs, key, expectedValue) {
+  async function assertLaunchArg(key, expectedValue) {
     await expect(element(by.id(`launchArg-${key}.name`))).toBeVisible();
     await expect(element(by.id(`launchArg-${key}.value`))).toHaveText(expectedValue);
   }
@@ -21,9 +21,9 @@ describe(':android: Launch arguments', () => {
     await device.launchApp({newInstance: true, launchArgs});
 
     await element(by.text('Launch Args')).tap();
-    await assertLaunchArg(launchArgs, 'hello', 'world');
-    await assertLaunchArg(launchArgs, 'seekthe', 'true');
-    await assertLaunchArg(launchArgs, 'heisthe', '1');
+    await assertLaunchArg('hello', 'world');
+    await assertLaunchArg('seekthe', 'true');
+    await assertLaunchArg('heisthe', '1');
   });
 
   it('should handle complex args', async () => {
@@ -41,8 +41,8 @@ describe(':android: Launch arguments', () => {
 
     await element(by.text('Launch Args')).tap();
 
-    await assertLaunchArg(launchArgs, 'complex', JSON.stringify(launchArgs.complex));
-    await assertLaunchArg(launchArgs, 'complexlist', JSON.stringify(launchArgs.complexlist));
+    await assertLaunchArg('complex', JSON.stringify(launchArgs.complex));
+    await assertLaunchArg('complexlist', JSON.stringify(launchArgs.complexlist));
   });
 
   // Ref: https://developer.android.com/studio/test/command-line#AMOptionsSyntax
@@ -57,7 +57,7 @@ describe(':android: Launch arguments', () => {
     await device.launchApp({newInstance: true, launchArgs});
 
     await element(by.text('Launch Args')).tap();
-    await assertLaunchArg(launchArgs, 'hello', 'world');
+    await assertLaunchArg('hello', 'world');
     await assertNoLaunchArg('debug');
     await assertNoLaunchArg('log');
     await assertNoLaunchArg('size');
diff --git a/detox/test/e2e/utils/notifications.js b/detox/test/e2e/utils/notifications.js
new file mode 100644
index 000000000..96f61adcf
--- /dev/null
+++ b/detox/test/e2e/utils/notifications.js
@@ -0,0 +1,55 @@
+const userNotificationPushTrigger = {
+  "trigger": {
+    "type": "push"
+  },
+  "title": "From push",
+  "subtitle": "Subtitle",
+  "body": "Body",
+  "badge": 1,
+  "payload": {
+    "key1": "value1",
+    "key2": "value2"
+  },
+  "category": "com.example.category",
+  "content-available": 0,
+  "action-identifier": "default"
+};
+
+const userNotificationCalendarTrigger = {
+  "trigger": {
+    "type": "calendar",
+    "date-components": {
+      "era": 1,
+      "year": 2017,
+      "month": 1,
+      "day": 1,
+      "hour": 0,
+      "minute": 0,
+      "second": 0,
+      "weekday": 0,
+      "weekdayOrdinal": 0,
+      "quarter": 1,
+      "weekOfMonth": 1,
+      "weekOfYear": 1,
+      "leapMonth": false
+    },
+    "repeats": true
+  },
+  "title": "From calendar",
+  "subtitle": "Subtitle",
+  "body": "From calendar",
+  "badge": 1,
+  "payload": {
+    "key1": "value1",
+    "key2": "value2"
+  },
+  "category": "com.example.category",
+  "user-text": "Hi there!",
+  "content-available": 0,
+  "action-identifier": "default"
+};
+
+module.exports = {
+  userNotificationPushTrigger,
+  userNotificationCalendarTrigger,
+};
diff --git a/detox/test/package.json b/detox/test/package.json
index 48bfeec5e..dfaad8aa5 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-test",
-  "version": "16.8.1",
+  "version": "16.8.2",
   "private": true,
   "engines": {
     "node": ">=8.3.0"
@@ -34,7 +34,7 @@
   },
   "devDependencies": {
     "@babel/core": "^7.4.5",
-    "detox": "^16.8.1",
+    "detox": "^16.8.2",
     "express": "^4.15.3",
     "jest": "26.x.x",
     "jest-circus": "26.x.x",
diff --git a/detox/test/src/Screens/AbstractArgsListScreen.js b/detox/test/src/Screens/AbstractArgsListScreen.js
new file mode 100644
index 000000000..a2b6e9699
--- /dev/null
+++ b/detox/test/src/Screens/AbstractArgsListScreen.js
@@ -0,0 +1,64 @@
+import _ from 'lodash';
+import React, { Component } from 'react';
+import {
+  Text,
+  View,
+} from 'react-native';
+
+export default class AbstractArgsListScreen extends Component {
+
+  constructor(props, contextName) {
+    super(props);
+    this.contextName = contextName;
+
+    this.state = {
+      argsList: undefined,
+    }
+  }
+
+  async componentDidMount() {
+    const argsList = await this.readArguments();
+    this.setState({
+      argsList,
+    })
+  }
+
+  readArguments() {
+    return Promise.reject('Not implemented');
+  }
+
+  getTitle() {
+    return Promise.reject('Not implemented');
+  }
+
+  render() {
+    return (
+      <View style={{flex: 1, paddingTop: 20, justifyContent: 'center', alignItems: 'center'}}>
+        {this.renderTitle()}
+
+        {this.state.argsList && this.renderArgsList()}
+
+        <Text style={{paddingTop: 20, fontWeight: 'bold'}}>Arguments count: </Text>
+        {this.state.argsList && <Text testID="argsCount">{Object.keys(this.state.argsList || {}).length}</Text>}
+      </View>
+    );
+  }
+
+  renderTitle() {
+    const title = this.getTitle();
+    return <Text style={{fontSize: 25, marginBottom: 30}}>{title}</Text>
+  }
+
+  renderArgsList() {
+    return _.reduce(this.state.argsList, (result, argValue, argName) => {
+      const itemId = `${this.contextName}-${argName}`;
+      const nameId = itemId + '.name';
+      const valueId = itemId + '.value';
+      result.push(
+        <Text key={nameId} testID={nameId} style={{fontWeight: 'bold'}}>{argName}:</Text>,
+        <Text key={valueId} testID={valueId} style={{paddingBottom: 4}}>{_.isArray(argValue) ? JSON.stringify(argValue) : argValue}</Text>,
+      );
+      return result;
+    }, []);
+  }
+}
diff --git a/detox/test/src/Screens/LaunchArgsScreen.js b/detox/test/src/Screens/LaunchArgsScreen.js
index ebf6d5de7..5f016203b 100644
--- a/detox/test/src/Screens/LaunchArgsScreen.js
+++ b/detox/test/src/Screens/LaunchArgsScreen.js
@@ -1,55 +1,21 @@
-import _ from 'lodash';
-import React, { Component } from 'react';
+import React from 'react';
 import {
-  Text,
-  View,
   NativeModules,
 } from 'react-native';
+import AbstractArgsListScreen from './AbstractArgsListScreen';
 
 const { NativeModule } = NativeModules;
 
-export default class LaunchArgsScreen extends Component {
-
+export default class LaunchArgsScreen extends AbstractArgsListScreen {
   constructor(props) {
-    super(props);
-
-    this.state = {
-      launchArgs: undefined,
-    }
-  }
-
-  async componentDidMount() {
-    const launchArgs = await NativeModule.getLaunchArguments();
-    this.setState({
-      launchArgs,
-    })
+    super(props, 'launchArg');
   }
 
-  render() {
-    return (
-      <View style={{flex: 1, paddingTop: 20, justifyContent: 'center', alignItems: 'center'}}>
-        <Text style={{fontSize: 25, marginBottom: 30}}>
-          Launch Arguments
-        </Text>
-
-        {this.state.launchArgs && this.renderLaunchArgsList()}
-
-        <Text style={{paddingTop: 20, fontWeight: 'bold'}}>Arguments count: </Text>
-        {this.state.launchArgs && <Text testID="launchArgsCount">{Object.keys(this.state.launchArgs || {}).length}</Text>}
-      </View>
-    );
+  async readArguments() {
+    return await NativeModule.getLaunchArguments();
   }
 
-  renderLaunchArgsList() {
-    return _.reduce(this.state.launchArgs, (result, argValue, argName) => {
-      const itemId = `launchArg-${argName}`;
-      const nameId = itemId + '.name';
-      const valueId = itemId + '.value';
-      result.push(
-        <Text key={nameId} testID={nameId} style={{fontWeight: 'bold'}}>{argName}:</Text>,
-        <Text key={valueId} testID={valueId} style={{paddingBottom: 4}}>{argValue}</Text>,
-      );
-      return result;
-    }, []);
+  getTitle() {
+    return 'Launch Arguments';
   }
 }
diff --git a/detox/test/src/Screens/LaunchNotificationScreen.js b/detox/test/src/Screens/LaunchNotificationScreen.js
new file mode 100644
index 000000000..df1914d76
--- /dev/null
+++ b/detox/test/src/Screens/LaunchNotificationScreen.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import {
+  NativeModules,
+} from 'react-native';
+import AbstractArgsListScreen from './AbstractArgsListScreen';
+
+const { NativeModule } = NativeModules;
+
+export default class LaunchNotificationScreen extends AbstractArgsListScreen {
+  constructor(props) {
+    super(props, 'notificationData');
+  }
+
+  async readArguments() {
+    return await NativeModule.parseNotificationData(null);
+  }
+
+  getTitle() {
+    return 'Launch-notification Data';
+  }
+}
diff --git a/detox/test/src/Screens/index.js b/detox/test/src/Screens/index.js
index b6abf09fc..2eb920678 100644
--- a/detox/test/src/Screens/index.js
+++ b/detox/test/src/Screens/index.js
@@ -18,6 +18,7 @@ import ShakeScreen from './ShakeScreen';
 import DatePickerScreen from './DatePickerScreen';
 import LanguageScreen from './LanguageScreen';
 import LaunchArgsScreen from './LaunchArgsScreen';
+import LaunchNotificationScreen from './LaunchNotificationScreen';
 import PickerViewScreen from './PickerViewScreen';
 import DeviceScreen from './DeviceScreen';
 
@@ -43,5 +44,6 @@ export {
   PickerViewScreen,
   LanguageScreen,
   LaunchArgsScreen,
+  LaunchNotificationScreen,
   DeviceScreen
 };
diff --git a/detox/test/src/app.js b/detox/test/src/app.js
index 231b2408c..d8fa4e681 100644
--- a/detox/test/src/app.js
+++ b/detox/test/src/app.js
@@ -7,7 +7,6 @@ import {
   Platform,
   NativeModules,
 } from 'react-native';
-
 import * as Screens from './Screens';
 
 const isAndroid = Platform.OS === 'android';
@@ -28,6 +27,14 @@ class example extends Component {
     Linking.addEventListener('url', (params) => this._handleOpenURL(params));
   }
 
+  async componentDidMount() {
+    const url = await Linking.getInitialURL();
+    if (url) {
+      console.log('App@didMount: Found pending URL', url);
+      this.setState({url: url});
+    }
+  }
+
   renderButton(title, onPressCallback) {
     return (
       <TouchableOpacity onPress={() => {
@@ -58,93 +65,81 @@ class example extends Component {
     );
   }
 
-  async componentDidMount() {
-    Linking.addEventListener('url', (params) => this._handleOpenURL(params));
-
-    const url = await Linking.getInitialURL();
-    if (url) {
-      console.log('App@didMount: Found pending URL', url);
-      this.setState({url: url});
-    }
+  renderInlineSeparator() {
+    return <Text style={{width: 10}}> | </Text>;
   }
 
   render() {
-    if (this.state.notification) {
-      console.log("App@render: rendering a notification", this.state.notification);
-      if (this.state.notification.title) {
-        return this.renderText(this.state.notification.title);
-      } else {
-        return this.renderText(this.state.notification);
-      }
-
-    }
-
-    else if (this.state.url) {
+    if (this.state.url) {
       console.log("App@render: rendering a URL:", this.state.url);
       return this.renderText(this.state.url);
     }
 
-    if (!this.state.screen) {
-		  console.log("App@render: JS rendering main screen");
-      return (
-        <View style={{flex: 1, paddingTop: 10, justifyContent: 'center', alignItems: 'center'}}>
-          <Text style={{fontSize: 20, marginBottom: 10}}>
-            Choose a test
-          </Text>
-          {this.renderScreenButton('Language', Screens.LanguageScreen)}
-          {this.renderScreenButton('Sanity', Screens.SanityScreen)}
-          {this.renderScreenButton('Matchers', Screens.MatchersScreen)}
-          {this.renderScreenButton('Actions', Screens.ActionsScreen)}
-          {this.renderScreenButton('Integrative Actions', Screens.IntegActionsScreen)}
-          {this.renderScreenButton('FS Scroll Actions', Screens.ScrollActionsScreen)}
-          {this.renderScreenButton('Assertions', Screens.AssertionsScreen)}
-          {this.renderScreenButton('WaitFor', Screens.WaitForScreen)}
-          {this.renderScreenButton('Stress', Screens.StressScreen)}
-          {this.renderScreenButton('Switch Root', Screens.SwitchRootScreen)}
-          {this.renderScreenButton('Timeouts', Screens.TimeoutsScreen)}
-          {this.renderScreenButton('Orientation', Screens.Orientation)}
-          {this.renderScreenButton('Permissions', Screens.Permissions)}
-          {this.renderScreenButton('Network', Screens.NetworkScreen)}
-          {this.renderAnimationScreenButtons()}
-          {this.renderScreenButton('Device', Screens.DeviceScreen)}
-          {this.renderScreenButton('Location', Screens.LocationScreen)}
-          {!isAndroid && this.renderScreenButton('DatePicker', Screens.DatePickerScreen)}
-          {!isAndroid && this.renderScreenButton('Picker', Screens.PickerViewScreen)}
-
-          { /* TODO: Push this into a dedicated screen */ }
-          <View style={{flexDirection: 'row', justifyContent: 'center'}}>
-            {this.renderButton('Crash', () => {
-              // Note: this crashes the native-modules thread (and thus an *uncaught* exception, on Android).
-              throw new Error('Simulated Crash');
-            })}
-            {isAndroid && <Text style={{width: 10}}> | </Text>}
-            {isAndroid && this.renderButton('UI Crash', () => {
-              // Killing main-thread while handling a tap will evidently cause
-              // the tap-action itself to fail and thus for an error to be responded
-              NativeModule.crashMainThread();
-            })}
-            {isAndroid && <Text style={{width: 10}}> | </Text>}
-            {isAndroid && this.renderButton('ANR', () => {
-              NativeModule.chokeMainThread();
-            })}
-          </View>
-
-          {this.renderScreenButton('Shake', Screens.ShakeScreen)}
-          {isAndroid && this.renderScreenButton('Launch Args', Screens.LaunchArgsScreen)}
-        </View>
-      );
+    if (this.state.screen) {
+      console.log("App@render: JS rendering screen");
+      const Screen = this.state.screen;
+      return <Screen/>;
     }
-    const Screen = this.state.screen;
+
+    console.log("App@render: JS rendering main screen");
     return (
-      <Screen/>
+      <View style={{flex: 1, paddingTop: 10, justifyContent: 'center', alignItems: 'center'}}>
+        <Text style={{fontSize: 20, marginBottom: 10}}>
+          Choose a test
+        </Text>
+        {this.renderScreenButton('Language', Screens.LanguageScreen)}
+        {this.renderScreenButton('Sanity', Screens.SanityScreen)}
+        {this.renderScreenButton('Matchers', Screens.MatchersScreen)}
+        {this.renderScreenButton('Actions', Screens.ActionsScreen)}
+        {this.renderScreenButton('Integrative Actions', Screens.IntegActionsScreen)}
+        {this.renderScreenButton('FS Scroll Actions', Screens.ScrollActionsScreen)}
+        {this.renderScreenButton('Assertions', Screens.AssertionsScreen)}
+        {this.renderScreenButton('WaitFor', Screens.WaitForScreen)}
+        {this.renderScreenButton('Stress', Screens.StressScreen)}
+        {this.renderScreenButton('Switch Root', Screens.SwitchRootScreen)}
+        {this.renderScreenButton('Timeouts', Screens.TimeoutsScreen)}
+        {this.renderScreenButton('Orientation', Screens.Orientation)}
+        {this.renderScreenButton('Permissions', Screens.Permissions)}
+        {this.renderScreenButton('Network', Screens.NetworkScreen)}
+        {this.renderAnimationScreenButtons()}
+        {this.renderScreenButton('Device', Screens.DeviceScreen)}
+        {this.renderScreenButton('Location', Screens.LocationScreen)}
+        {!isAndroid && this.renderScreenButton('DatePicker', Screens.DatePickerScreen)}
+        {!isAndroid && this.renderScreenButton('Picker', Screens.PickerViewScreen)}
+
+        { /* TODO: Push this into a dedicated screen */ }
+        <View style={{flexDirection: 'row', justifyContent: 'center'}}>
+          {this.renderButton('Crash', () => {
+            // Note: this crashes the native-modules thread (and thus an *uncaught* exception, on Android).
+            throw new Error('Simulated Crash');
+          })}
+          {isAndroid && this.renderInlineSeparator()}
+          {isAndroid && this.renderButton('UI Crash', () => {
+            // Killing main-thread while handling a tap will evidently cause
+            // the tap-action itself to fail and thus for an error to be responded
+            NativeModule.crashMainThread();
+          })}
+          {isAndroid && this.renderInlineSeparator()}
+          {isAndroid && this.renderButton('ANR', () => {
+            NativeModule.chokeMainThread();
+          })}
+        </View>
+
+        {this.renderScreenButton('Shake', Screens.ShakeScreen)}
+        <View style={{flexDirection: 'row', justifyContent: 'center'}}>
+          {isAndroid && this.renderScreenButton('Launch Args', Screens.LaunchArgsScreen)}
+          {isAndroid && this.renderInlineSeparator()}
+          {isAndroid && this.renderScreenButton('Launch-Notification', Screens.LaunchNotificationScreen)}
+        </View>
+      </View>
     );
-  }
+}
 
   renderAnimationScreenButtons() {
     return (
       <View style={{flexDirection: 'row', justifyContent: 'center'}}>
         {this.renderScreenButton('RN Animations', Screens.RNAnimationsScreen)}
-        {isAndroid && <Text style={{width: 10}}> | </Text>}
+        {isAndroid && this.renderInlineSeparator()}
         {isAndroid && this.renderScreenButton('Native Animation', Screens.NativeAnimationsScreen)}
       </View>
     );
diff --git a/examples/demo-react-native-jest/e2eExplicitRequire/config.json b/examples/demo-react-native-jest/e2eExplicitRequire/config.json
new file mode 100644
index 000000000..5467a9303
--- /dev/null
+++ b/examples/demo-react-native-jest/e2eExplicitRequire/config.json
@@ -0,0 +1,8 @@
+{
+    "testEnvironment": "./environment",
+    "testRunner": "jest-circus/runner",
+    "testTimeout": 120000,
+    "testRegex": "\\.e2e\\.js$",
+    "reporters": ["detox/runners/jest/streamlineReporter"],
+    "verbose": true
+}
diff --git a/examples/demo-react-native-jest/e2eExplicitRequire/environment.js b/examples/demo-react-native-jest/e2eExplicitRequire/environment.js
new file mode 100644
index 000000000..ad46bec5f
--- /dev/null
+++ b/examples/demo-react-native-jest/e2eExplicitRequire/environment.js
@@ -0,0 +1,23 @@
+const {
+  DetoxCircusEnvironment,
+  SpecReporter,
+  WorkerAssignReporter,
+} = require('detox/runners/jest-circus');
+
+class CustomDetoxEnvironment extends DetoxCircusEnvironment {
+  constructor(config) {
+    super(config);
+
+    // Can be safely removed, if you are content with the default value (=300000ms)
+    this.initTimeout = 300000;
+
+    // This takes care of generating status logs on a per-spec basis. By default, Jest only reports at file-level.
+    // This is strictly optional.
+    this.registerListeners({
+      SpecReporter,
+      WorkerAssignReporter,
+    });
+  }
+}
+
+module.exports = CustomDetoxEnvironment;
diff --git a/examples/demo-react-native-jest/e2eExplicitRequire/example.e2e.js b/examples/demo-react-native-jest/e2eExplicitRequire/example.e2e.js
new file mode 100644
index 000000000..c33f5a319
--- /dev/null
+++ b/examples/demo-react-native-jest/e2eExplicitRequire/example.e2e.js
@@ -0,0 +1,26 @@
+const {device, expect, element, by, waitFor} = require('detox');
+
+describe('Example (explicit)', () => {
+  beforeEach(async () => {
+    await device.reloadReactNative();
+  });
+  
+  it('should have welcome screen', async () => {
+    await expect(element(by.id('welcome'))).toBeVisible();
+  });
+  
+  it('should show hello screen after tap', async () => {
+    await element(by.id('hello_button')).tap();
+    await expect(element(by.text('Hello!!!'))).toBeVisible();
+  });
+  
+  it('should show world screen after tap', async () => {
+    await element(by.id('world_button')).tap();
+    await expect(element(by.text('World!!!'))).toBeVisible();
+  });
+
+  it('waitFor should be exported', async () => {
+    await waitFor(element(by.id('welcome'))).toExist().withTimeout(2000);
+    await expect(element(by.id('welcome'))).toExist();
+  });
+});