Repository: wix/Detox (js)

Issue:
Introduce basic support for push notifications mocking on Android
### Description

Define and implement an API for mocking push notification invocation events on Android, so as to introduce parity with the equivalent API for iOS.

This has long been a missing functionality on Android compared to iOS, as - unlike on iOS, there's no skeletal definition of notification invocation events. Rather, on Android, notifications fire pending intents, originally produced upon notification arrival handling *by the app itself*; All the while there's also no restriction as to how and when notifications show, and what the nature of the registered pending intent is.

The general direction is towards formalizing a reduced intents API, to be used by the JS test code.

#### Phase 1

In this basic-support phase, we limit the functionality to a naive one where the same intent is always used*, and the user is only able to provide payload, using the API's already defined for iOS.

This boils down to 3 types of use cases:

```js
    const userNotification = {
      payload: {
        userData1: 'userDataValue1',
        userData2: 'userDataValue2',
      },
    };

    // #1 - most common
    await device.launchApp({ newInstance: true, userNotification });

    // #2 - `launchApp` used for a running app (a not very appealing API overall)
    await device.launchApp({ newInstance: false, userNotification });

    // #3 - The dedicated function
    await device.launchApp({newInstance: true});
    await device.sendUserNotification(userNotification);
```
> Note: while `sendUserNotification()` is an API that's not very idiomatic on Android, we use it here nonetheless, assuming we will abandon all of this in phase 2 (see below).

#### Phase 2

For the full vision and more versatile Android-idiomatic solution, see #2141.

---
<sup> * This refers to a simply, launcher-like intent, with user payload "flat"-set in its extras -- much like the way the default FCM impl. handles *message* notifications on the device.</sup>

Pull request:
Introduce basic support for all user-notification API's in all relevant use cases
- [ ] This is a small change 
- [x] This change has been discussed in issue #1192 and the solution has been agreed upon with maintainers.

---

**Description:**
Resolves #1192, by filling the missing gaps - as described in #2134, namely, by adding support for these two API usage cases:

```js
await device.launchApp({ newInstance: false, userNotification });
```

and:
```js
await device.sendUserNotification(userNotification);
```

---
In the process, I've also cleaned up and aligned the way URL-based launches are done. Formerly, the driver used to hold a transient pending-url property for the time between `deliverPayload` and `launchApp()` are called (by `device.launchApp()`). This has been removed, and `deliverPayload` is now completely ignored in this use case, as it is effectively irrelevant for Android.

Patch:
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 568e94cb8..bc5717397 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
@@ -110,8 +110,7 @@ public final class Detox {
      * @param activityTestRule the activityTestRule
      */
     public static void runTests(ActivityTestRule activityTestRule) {
-        Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext();
-        runTests(activityTestRule, appContext);
+        runTests(activityTestRule, getAppContext());
     }
 
     /**
@@ -122,8 +121,7 @@ public final class Detox {
      *                         the {@link IdlingPolicies} API.
      */
     public static void runTests(ActivityTestRule activityTestRule, DetoxIdlePolicyConfig idlePolicyConfig) {
-        Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext();
-        runTests(activityTestRule, appContext, idlePolicyConfig);
+        runTests(activityTestRule, getAppContext(), idlePolicyConfig);
     }
 
     /**
@@ -194,6 +192,12 @@ public final class Detox {
         launchActivitySync(sIntentsFactory.intentWithUrl(url, false));
     }
 
+    public static void startActivityFromNotification(String dataFilePath) {
+        Bundle notificationData = new NotificationDataParser(dataFilePath).parseNotificationData();
+        Intent intent = sIntentsFactory.intentWithNotificationData(getAppContext(), notificationData, false);
+        launchActivitySync(intent);
+    }
+
     private static Intent extractInitialIntent() {
         Intent intent;
 
@@ -201,7 +205,7 @@ public final class Detox {
             intent = sIntentsFactory.intentWithUrl(sLaunchArgs.getUrlOverride(), true);
         } else if (sLaunchArgs.hasNotificationPath()) {
             Bundle notificationData = new NotificationDataParser(sLaunchArgs.getNotificationPath()).parseNotificationData();
-            intent = sIntentsFactory.intentWithNotificationData(notificationData, true);
+            intent = sIntentsFactory.intentWithNotificationData(getAppContext(), notificationData, true);
         } else {
             intent = sIntentsFactory.cleanIntent();
         }
@@ -231,4 +235,8 @@ public final class Detox {
         instrumentation.addMonitor(activityMonitor);
         instrumentation.waitForMonitorWithTimeout(activityMonitor, ACTIVITY_LAUNCH_TIMEOUT);
     }
+
+    private static Context getAppContext() {
+        return InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext();
+    }
 }
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
index f3fa0e150..43bed23a4 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/LaunchIntentsFactory.kt
+++ b/detox/android/detox/src/main/java/com/wix/detox/LaunchIntentsFactory.kt
@@ -1,6 +1,7 @@
 package com.wix.detox
 
 import android.app.Activity
+import android.content.Context
 import android.content.Intent
 import android.net.Uri
 import android.os.Bundle
@@ -21,9 +22,9 @@ internal class LaunchIntentsFactory {
         }
 
     /**
-     * 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)
+     * Constructs a near-empty, activity-anonymous intent, assuming an ActivityTestRule instance that would handle it
+     * and 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.
      */
@@ -67,9 +68,10 @@ internal class LaunchIntentsFactory {
      *
      * @return The resulting intent.
      */
-    fun intentWithNotificationData(data: Bundle, initialLaunch: Boolean)
+    fun intentWithNotificationData(appContext: Context, data: Bundle, initialLaunch: Boolean)
         = Intent(Intent.ACTION_MAIN).apply {
             addCategory(Intent.CATEGORY_LAUNCHER)
+            setPackage(appContext.packageName)
             putExtras(data)
             flags = coreFlags
             if (initialLaunch) {
diff --git a/detox/src/android/espressoapi/Detox.js b/detox/src/android/espressoapi/Detox.js
index ef7675e07..bbba04e3a 100644
--- a/detox/src/android/espressoapi/Detox.js
+++ b/detox/src/android/espressoapi/Detox.js
@@ -46,6 +46,18 @@ class Detox {
     };
   }
 
+  static startActivityFromNotification(dataFilePath) {
+    if (typeof dataFilePath !== "string") throw new Error("dataFilePath should be a string, but got " + (dataFilePath + (" (" + (typeof dataFilePath + ")"))));
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.Detox"
+      },
+      method: "startActivityFromNotification",
+      args: [dataFilePath]
+    };
+  }
+
   static extractInitialIntent() {
     return {
       target: {
@@ -57,6 +69,17 @@ class Detox {
     };
   }
 
+  static getAppContext() {
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.Detox"
+      },
+      method: "getAppContext",
+      args: []
+    };
+  }
+
 }
 
 module.exports = Detox;
\ No newline at end of file
diff --git a/detox/src/devices/Device.js b/detox/src/devices/Device.js
index 66e3944a0..fdf7f0274 100644
--- a/detox/src/devices/Device.js
+++ b/detox/src/devices/Device.js
@@ -19,14 +19,6 @@ class Device {
     await this.deviceDriver.prepare();
   }
 
-  createPayloadFileAndUpdatesParamsObject(key, launchKey, params, baseLaunchArgs) {
-    const payloadFilePath = this.deviceDriver.createPayloadFile(params[key]);
-    baseLaunchArgs[launchKey] = payloadFilePath;
-    //`params` will be used later for `deliverPayload`, so remove the actual notification and add the file URL
-    delete params[key];
-    params[launchKey] = payloadFilePath;
-  }
-
   async launchApp(params = {newInstance: false}, bundleId) {
     const payloadParams = ['url', 'userNotification', 'userActivity'];
     const hasPayload = this._assertHasSingleParam(payloadParams, params);
@@ -48,9 +40,9 @@ class Device {
         baseLaunchArgs['detoxSourceAppOverride'] = params.sourceApp;
       }
     } else if (params.userNotification) {
-      this.createPayloadFileAndUpdatesParamsObject('userNotification', 'detoxUserNotificationDataURL', params, baseLaunchArgs);
+      this._createPayloadFileAndUpdatesParamsObject('userNotification', 'detoxUserNotificationDataURL', params, baseLaunchArgs);
     } else if (params.userActivity) {
-      this.createPayloadFileAndUpdatesParamsObject('userActivity', 'detoxUserActivityDataURL', params, baseLaunchArgs);
+      this._createPayloadFileAndUpdatesParamsObject('userActivity', 'detoxUserActivityDataURL', params, baseLaunchArgs);
     }
 
     if (params.permissions) {
@@ -104,6 +96,14 @@ class Device {
     return this.deviceDriver.takeScreenshot(this._deviceId, name);
   }
 
+  _createPayloadFileAndUpdatesParamsObject(key, launchKey, params, baseLaunchArgs) {
+    const payloadFilePath = this.deviceDriver.createPayloadFile(params[key]);
+    baseLaunchArgs[launchKey] = payloadFilePath;
+    //`params` will be used later for `predeliverPayload`, so remove the actual notification and add the file URL
+    delete params[key];
+    params[launchKey] = payloadFilePath;
+  }
+
   _isAppInBackground(params, _bundleId) {
     return !params.delete && !params.newInstance && this._processes[_bundleId];
   }
@@ -189,7 +189,7 @@ class Device {
       throw new Error(`openURL must be called with JSON params, and a value for 'url' key must be provided. example: await device.openURL({url: "url", sourceApp[optional]: "sourceAppBundleID"}`);
     }
 
-    await this.deviceDriver.deliverPayload(params);
+    await this.deviceDriver.deliverPayload(params, this._deviceId);
   }
 
   async shutdown() {
@@ -220,9 +220,10 @@ class Device {
 
   async _sendPayload(key, params) {
     const payloadFilePath = this.deviceDriver.createPayloadFile(params);
-    let payload = {};
-    payload[key] = payloadFilePath;
-    await this.deviceDriver.deliverPayload(payload);
+    const payload = {
+      [key]: payloadFilePath,
+    };
+    await this.deviceDriver.deliverPayload(payload, this._deviceId);
     this.deviceDriver.cleanupRandomDirectory(payloadFilePath);
   }
 
diff --git a/detox/src/devices/drivers/android/AndroidDriver.js b/detox/src/devices/drivers/android/AndroidDriver.js
index abb5b4f1a..0697ebf4b 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.js
@@ -50,8 +50,6 @@ class AndroidDriver extends DeviceDriverBase {
     this.aapt = new AAPT();
     this.fileXfer = new TempFileXfer(this.adb);
     this.devicePathBuilder = new AndroidDevicePathBuilder();
-
-    this.pendingUrl = undefined;
   }
 
   declareArtifactPlugins() {
@@ -87,27 +85,27 @@ class AndroidDriver extends DeviceDriverBase {
   }
 
   async launchApp(deviceId, bundleId, launchArgs, languageAndLocale) {
+    let notificationPayloadTargetPath;
+
     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');
-
+      notificationPayloadTargetPath = await this._sendNotificationDataToDevice(launchArgs.detoxUserNotificationDataURL, deviceId);
       launchArgs = {
         ...launchArgs,
-        detoxUserNotificationDataURL: destNotificationDataFile,
+        detoxUserNotificationDataURL: notificationPayloadTargetPath,
       }
     }
 
     if (!this._isInstrumentationRunning()) {
       await this._launchInstrumentationProcess(deviceId, bundleId, launchArgs);
       await sleep(500);
+    } else if (launchArgs.detoxURLOverride) {
+      await this._startActivityWithUrl(launchArgs.detoxURLOverride);
+    } else if (launchArgs.detoxUserNotificationDataURL) {
+      await this._startActivityFromNotification(launchArgs.detoxUserNotificationDataURL);
     } else {
-      if (this.pendingUrl) {
-        await this._startActivityWithUrl(this._getAndClearPendingUrl());
-      } else {
-        await this._resumeMainActivity();
-      }
+      await this._resumeMainActivity();
     }
 
     let pid = NaN;
@@ -122,14 +120,18 @@ class AndroidDriver extends DeviceDriverBase {
     return pid;
   }
 
-  async deliverPayload(params) {
-    const {delayPayload, url} = params;
+  async deliverPayload(params, deviceId) {
+    if (params.delayPayload) {
+      return;
+    }
 
+    const {url, detoxUserNotificationDataURL} = params;
     if (url) {
-      await (delayPayload ? this._setPendingUrl(url) : this._startActivityWithUrl(url));
+      await this._startActivityWithUrl(url);
+    } else if (detoxUserNotificationDataURL) {
+      const payloadPathOnDevice = await this._sendNotificationDataToDevice(detoxUserNotificationDataURL, deviceId);
+      await this._startActivityFromNotification(payloadPathOnDevice);
     }
-
-    // Other payload content types are not yet supported.
   }
 
   async waitUntilReady() {
@@ -314,20 +316,19 @@ class AndroidDriver extends DeviceDriverBase {
     return NaN;
   }
 
-  _setPendingUrl(url) {
-    this.pendingUrl = url;
-  }
-
-  _getAndClearPendingUrl() {
-    const pendingUrl = this.pendingUrl;
-    this.pendingUrl = undefined;
-    return pendingUrl;
+  async _sendNotificationDataToDevice(dataFileLocalPath, deviceId) {
+    await this.fileXfer.prepareDestinationDir(deviceId);
+    return await this.fileXfer.send(deviceId, dataFileLocalPath, 'notification.json');
   }
 
   _startActivityWithUrl(url) {
     return this.invocationManager.execute(DetoxApi.startActivityFromUrl(url));
   }
 
+  _startActivityFromNotification(dataFilePath) {
+    return this.invocationManager.execute(DetoxApi.startActivityFromNotification(dataFilePath));
+  }
+
   _resumeMainActivity() {
     return this.invocationManager.execute(DetoxApi.launchMainActivity());
   }


Test patch:
diff --git a/detox/src/devices/Device.test.js b/detox/src/devices/Device.test.js
index 851fb3a4c..545f92e73 100644
--- a/detox/src/devices/Device.test.js
+++ b/detox/src/devices/Device.test.js
@@ -573,7 +573,7 @@ describe('Device', () => {
     const device = validDevice();
     await device.openURL({url: 'url'});
 
-    expect(driverMock.driver.deliverPayload).toHaveBeenCalledWith({url: 'url'});
+    expect(driverMock.driver.deliverPayload).toHaveBeenCalledWith({url: 'url'}, device._deviceId);
   });
 
   it(`openURL(notAnObject) should pass to device driver`, async () => {
diff --git a/detox/src/devices/drivers/android/AndroidDriver.test.js b/detox/src/devices/drivers/android/AndroidDriver.test.js
index 2daa010f9..5eb7c2892 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.test.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.test.js
@@ -34,11 +34,18 @@ describe('Android driver', () => {
   }
   MockInstrumentationLogsParserClass.INSTRUMENTATION_STACKTRACE_MOCK = 'Stacktrace mock';
 
+  class MockInvocationManagerClass {
+    constructor() {
+      Object.assign(this, invocationManager);
+    }
+  }
+
   let logger;
   let client;
   let getAbsoluteBinaryPath;
   let fs;
   let exec;
+  let detoxApi;
   beforeEach(() => {
     jest.mock('fs', () => ({
       existsSync: jest.fn(),
@@ -86,6 +93,9 @@ describe('Android driver', () => {
       },
       waitUntilReady: jest.fn(),
     };
+
+    jest.mock('../../../android/espressoapi/Detox');
+    detoxApi = require('../../../android/espressoapi/Detox');
   });
 
   let adb;
@@ -113,6 +123,15 @@ describe('Android driver', () => {
     jest.mock('./tools/TempFileXfer', () => MockTempFileXferClass);
   });
 
+  let invocationManager;
+  beforeEach(() => {
+    const InvocationManager = jest.genMockFromModule('../../../invoke').InvocationManager;
+    invocationManager = new InvocationManager();
+    jest.mock('../../../invoke', () => ({
+      InvocationManager: MockInvocationManagerClass,
+    }))
+  });
+
   let uut;
   beforeEach(() => {
     const AndroidDriver = require('./AndroidDriver');
@@ -136,41 +155,191 @@ describe('Android driver', () => {
     });
   });
 
-  describe('Notification data handling (user-notification-data-URL arg)', () => {
-    const launchArgs = Object.freeze({
+  describe('URL runtime delivery handling', () => {
+    const detoxURLOverride = 'schema://android-url';
+
+    const detoxApiInvocation = {
+      method: 'startActivityFromUrl-mocked'
+    };
+    const mockStartActivityInvokeApi = () => detoxApi.startActivityFromUrl.mockReturnValue(detoxApiInvocation);
+    const assertActivityStartInvoked = () => {
+      expect(invocationManager.execute).toHaveBeenCalledWith(detoxApiInvocation);
+      expect(detoxApi.startActivityFromUrl).toHaveBeenCalledWith(detoxURLOverride);
+    }
+    const assertActivityStartNotInvoked = () => expect(detoxApi.startActivityFromUrl).not.toHaveBeenCalled();
+
+    const assertInstrumentationSpawned = () => expect(adb.spawnInstrumentation).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.arrayContaining(['detoxURLOverride', detoxURLOverride]),
+      expect.anything(),
+    );
+    const resetInstrumentationMock = () => adb.spawnInstrumentation.mockReset();
+    const assertInstrumentationNotSpawned = () => expect(adb.spawnInstrumentation).not.toHaveBeenCalled();
+
+    describe('in app launch (with dedicated arg)', () => {
+      const args = {
+        detoxURLOverride,
+      };
+
+      it('should spawn instrumentation with the URL in a clean launch', async () => {
+        adb.getInstrumentationRunner.mockResolvedValue('mock test-runner');
+
+        await uut.launchApp(deviceId, bundleId, args, '');
+
+        assertInstrumentationSpawned();
+      });
+
+      it('should start the app with URL using invocation-manager', async () => {
+        mockStartActivityInvokeApi();
+
+        await uut.launchApp(deviceId, bundleId, {}, '');
+
+        resetInstrumentationMock();
+        await uut.launchApp(deviceId, bundleId, args, '');
+
+        assertActivityStartInvoked();
+        assertInstrumentationNotSpawned();
+      });
+    });
+
+    describe('via explicit payload-delivery call', () => {
+      const args = {
+        url: detoxURLOverride,
+      };
+      const argsDelayed = {
+        ...args,
+        delayPayload: true,
+      }
+
+      it('should start the app using invocation-manager', async () => {
+        mockStartActivityInvokeApi();
+
+        await uut.launchApp(deviceId, bundleId, {}, '')
+        await uut.deliverPayload(args, deviceId);
+
+        assertActivityStartInvoked();
+      });
+
+      it('should not start the app using invocation-manager', async () => {
+        mockStartActivityInvokeApi();
+
+        await uut.launchApp(deviceId, bundleId, {}, '')
+        await uut.deliverPayload(argsDelayed, deviceId);
+
+        assertActivityStartNotInvoked();
+      });
+    });
+
+  });
+
+  describe('Notification data handling', () => {
+    const notificationArgs = 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);
+    const detoxApiInvocation = {
+      method: 'startActivityFromNotification-mocked'
+    };
+    const mockStartActivityInvokeApi = () => detoxApi.startActivityFromNotification.mockReturnValue(detoxApiInvocation);
+    const assertActivityStartInvoked = () => {
+      expect(invocationManager.execute).toHaveBeenCalledWith(detoxApiInvocation);
+      expect(detoxApi.startActivityFromNotification).toHaveBeenCalledWith(mockNotificationDataTargetPath);
+    }
+    const assertActivityStartNotInvoked = () => {
+      expect(detoxApi.startActivityFromNotification).not.toHaveBeenCalled();
+    }
+    const resetInstrumentationMock = () => adb.spawnInstrumentation.mockReset();
+    const assertInstrumentationNotSpawned = () => expect(adb.spawnInstrumentation).not.toHaveBeenCalled();
+
+    describe('in app launch (with dedicated arg)', () => {
+      it('should prepare the device for receiving notification data file', async () => {
+        await uut.launchApp(deviceId, bundleId, notificationArgs, '');
+        expect(fileXfer.prepareDestinationDir).toHaveBeenCalledWith(deviceId);
+      });
+
+      it('should transfer the notification data file to the device', async () => {
+        await uut.launchApp(deviceId, bundleId, notificationArgs, '');
+        expect(fileXfer.send).toHaveBeenCalledWith(deviceId, notificationArgs.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, notificationArgs, '');
+          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, notificationArgs, '');
+
+        expect(adb.spawnInstrumentation).toHaveBeenCalledWith(
+          expect.anything(),
+          expect.arrayContaining(['detoxUserNotificationDataURL', mockNotificationDataTargetPath]),
+          undefined,
+        );
+      });
     });
 
-    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');
+    [
+      {
+        description: 'in 2nd app launch (i.e. when running)',
+        applyFn: () => uut.launchApp(deviceId, bundleId, notificationArgs, ''),
+      },
+      {
+        description: 'via explicit payload-delivery call',
+        applyFn: () => uut.deliverPayload(notificationArgs, deviceId),
+      },
+    ].forEach((spec) => {
+      describe(spec.description, () => {
+        beforeEach(async () => {
+          await uut.launchApp(deviceId, bundleId, {}, '')
+        });
+
+        it('should pre-transfer notification data to device', async () => {
+          resetInstrumentationMock();
+          await spec.applyFn();
+
+          expect(fileXfer.prepareDestinationDir).toHaveBeenCalledWith(deviceId);
+          expect(fileXfer.send).toHaveBeenCalledWith(deviceId, notificationArgs.detoxUserNotificationDataURL, 'notification.json');
+        });
+
+        it('should start the app with notification data using invocation-manager', async () => {
+          mockStartActivityInvokeApi();
+
+          resetInstrumentationMock();
+          await spec.applyFn();
+
+          assertActivityStartInvoked();
+          assertInstrumentationNotSpawned();
+        });
+      });
     });
 
-    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) {
+    describe('via explicit payload-delivery call', () => {
+      const notificationArgsDelayed = {
+        ...notificationArgs,
+        delayPayload: true,
       }
-    });
 
-    it('should launch instrument with a modified notification data URL arg', async () => {
-      fileXfer.send.mockReturnValue(mockNotificationDataTargetPath);
+      it('should not send notification data is payload send-out is set as delayed', async () => {
+        await uut.launchApp(deviceId, bundleId, {}, '')
+        await uut.deliverPayload(notificationArgsDelayed, deviceId);
 
-      await uut.launchApp(deviceId, bundleId, launchArgs, '');
+        expect(fileXfer.send).not.toHaveBeenCalled();
+      });
 
-      expect(adb.spawnInstrumentation).toHaveBeenCalledWith(
-        expect.anything(),
-        expect.arrayContaining(['detoxUserNotificationDataURL', mockNotificationDataTargetPath]),
-        undefined,
-      );
+      it('should not start the app using invocation-manager', async () => {
+        await uut.launchApp(deviceId, bundleId, {}, '')
+        await uut.deliverPayload(notificationArgsDelayed, deviceId);
+
+        assertActivityStartNotInvoked();
+      });
     });
+
   });
 
   describe('Device ready-wait', () => {
diff --git a/detox/test/android/app/src/main/java/com/example/MainActivity.java b/detox/test/android/app/src/main/java/com/example/MainActivity.java
index e84b72551..93702f0a9 100644
--- a/detox/test/android/app/src/main/java/com/example/MainActivity.java
+++ b/detox/test/android/app/src/main/java/com/example/MainActivity.java
@@ -1,5 +1,7 @@
 package com.example;
 
+import android.content.Intent;
+
 import com.facebook.react.ReactActivity;
 
 public class MainActivity extends ReactActivity {
@@ -12,4 +14,10 @@ public class MainActivity extends ReactActivity {
     protected String getMainComponentName() {
         return "example";
     }
+
+    @Override
+    public void onNewIntent(Intent intent) {
+        super.onNewIntent(intent);
+        setIntent(intent);
+    }
 }
diff --git a/detox/test/e2e/11.user-notifications.test.js b/detox/test/e2e/11.user-notifications.test.js
index 93014f1d2..cbf9acbcf 100644
--- a/detox/test/e2e/11.user-notifications.test.js
+++ b/detox/test/e2e/11.user-notifications.test.js
@@ -42,29 +42,58 @@ describe(':ios: User Notifications', () => {
 });
 
 describe(':android: User Notifications', () => {
-  async function assertNotificationData(key, expectedValue) {
+  const googleProjectId = 284440699462;
+  const userNotification = {
+    payload: {
+      from: googleProjectId,
+      userData: 'userDataValue',
+      userDataArray: ['rock', 'paper', 'scissors'],
+      sub: {
+        objects: 'are supported as well'
+      },
+      'google.sent_time': 1592133826891,
+      'google.ttl': 2419200,
+      'google.original_priority': 'high',
+      'collapse_key': 'com.wix.detox.test',
+    },
+  };
+
+  async function assertNotificationDataField(key, expectedValue) {
     await expect(element(by.id(`notificationData-${key}.name`))).toBeVisible();
     await expect(element(by.id(`notificationData-${key}.value`))).toHaveText(expectedValue);
   }
 
-  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',
-      },
-    };
+  async function assertNotificationDataExtensively() {
+    await assertNotificationDataField('from', googleProjectId.toString());
+    await assertNotificationDataField('userData', userNotification.payload.userData);
+    await assertNotificationDataField('userDataArray', JSON.stringify(userNotification.payload.userDataArray));
+    await assertNotificationDataField('sub', JSON.stringify(userNotification.payload.sub));
+  }
+
+  async function assertNotificationData() {
+    await assertNotificationDataField('userData', userNotification.payload.userData);
+  }
+
+  it('should launch app with data', async () => {
     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));
+    await assertNotificationDataExtensively();
+  });
+
+  it('should resume app with data', async () => {
+    await device.launchApp({ newInstance: true });
+    console.log('Sending app to background...');
+    await device.sendToHome();
+    console.log('Resuming app with user notification');
+    await device.launchApp({ newInstance: false, userNotification });
+    await element(by.text('Launch-Notification')).tap();
+    await assertNotificationData();
+  });
+
+  it('should apply notification using sendUserNotification() when app is running', async () => {
+    await device.launchApp({newInstance: true});
+    await device.sendUserNotification(userNotification);
+    await element(by.text('Launch-Notification')).tap();
+    await assertNotificationData();
   });
 });
diff --git a/detox/test/src/Screens/AbstractArgsListScreen.js b/detox/test/src/Screens/AbstractArgsListScreen.js
index a2b6e9699..c4afde9c3 100644
--- a/detox/test/src/Screens/AbstractArgsListScreen.js
+++ b/detox/test/src/Screens/AbstractArgsListScreen.js
@@ -51,12 +51,13 @@ export default class AbstractArgsListScreen extends Component {
 
   renderArgsList() {
     return _.reduce(this.state.argsList, (result, argValue, argName) => {
+      const _argValue = (_.isArray(argValue) || _.isObject(argValue) ? JSON.stringify(argValue) : argValue);
       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>,
+        <Text key={valueId} testID={valueId} style={{paddingBottom: 4}}>{_argValue}</Text>,
       );
       return result;
     }, []);