Repository: wix/Detox
Pull request #1808 (2019-12-18 09:13:53)

Implement DTXProfiler subset for Android
Added basic integration with DetoxInstruments for Android.


Patch:
diff --git a/detox/android/detox/build.gradle b/detox/android/detox/build.gradle
index 035869b0e..6e95ff383 100644
--- a/detox/android/detox/build.gradle
+++ b/detox/android/detox/build.gradle
@@ -108,8 +108,8 @@ if (rootProject.hasProperty('isOfficialDetoxLib') ||
     }
 
     dependencies {
-        testImplementation 'org.spekframework.spek2:spek-dsl-jvm:2.0.7'
-        testImplementation 'org.spekframework.spek2:spek-runner-junit5:2.0.7'
+        testImplementation 'org.spekframework.spek2:spek-dsl-jvm:2.0.9'
+        testImplementation 'org.spekframework.spek2:spek-runner-junit5:2.0.9'
         testImplementation "org.jetbrains.kotlin:kotlin-reflect:$_kotlinVersion"
     }
 }
diff --git a/detox/android/detox/src/main/java/com/wix/detox/DetoxActionHandlers.kt b/detox/android/detox/src/main/java/com/wix/detox/DetoxActionHandlers.kt
index 9690b75d3..f228868c3 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/DetoxActionHandlers.kt
+++ b/detox/android/detox/src/main/java/com/wix/detox/DetoxActionHandlers.kt
@@ -3,6 +3,8 @@ package com.wix.detox
 import android.content.Context
 import android.util.Log
 import androidx.test.espresso.IdlingResource
+import com.wix.detox.instruments.DetoxInstrumentsException
+import com.wix.detox.instruments.DetoxInstrumentsManager
 import com.wix.invoke.MethodInvocation
 import org.json.JSONArray
 import org.json.JSONException
@@ -110,3 +112,61 @@ class QueryStatusActionHandler(
             })
         }
 }
+
+class InstrumentsRecordingStateActionHandler(
+        private val instrumentsManager: DetoxInstrumentsManager,
+        private val wsClient: WebSocketClient
+) : DetoxActionHandler {
+
+    override fun handle(params: String, messageId: Long) {
+        val recordingPath = JSONObject(params).optString("recordingPath", null)
+        if (recordingPath != null) {
+            instrumentsManager.startRecordingAtLocalPath(recordingPath)
+        } else {
+            instrumentsManager.stopRecording()
+        }
+
+        wsClient.sendAction("setRecordingStateDone", emptyMap<String, Any>(), messageId)
+    }
+}
+
+class InstrumentsEventsActionsHandler(
+        private val instrumentsManager: DetoxInstrumentsManager,
+        private val wsClient: WebSocketClient
+) : DetoxActionHandler {
+
+    override fun handle(params: String, messageId: Long) {
+        with (JSONObject(params))  {
+            when (getString("action")) {
+                "begin" -> {
+                    instrumentsManager.eventBeginInterval(
+                            getString("category"),
+                            getString("name"),
+                            getString("id"),
+                            getString("additionalInfo")
+                    )
+                }
+                "end" -> {
+                    instrumentsManager.eventEndInterval(
+                            getString("id"),
+                            getString("status"),
+                            getString("additionalInfo")
+                    )
+                }
+                "mark" -> {
+                    instrumentsManager.eventMark(
+                            getString("category"),
+                            getString("name"),
+                            getString("id"),
+                            getString("status"),
+                            getString("additionalInfo")
+                    )
+                }
+                else -> throw DetoxInstrumentsException("Invalid action")
+            }
+        }
+
+        wsClient.sendAction("eventDone", emptyMap<String, Any>(), messageId)
+    }
+
+}
\ No newline at end of file
diff --git a/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java b/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java
index 2ff129231..b6775e616 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java
@@ -7,6 +7,7 @@ import android.os.Looper;
 import androidx.annotation.NonNull;
 import android.util.Log;
 
+import com.wix.detox.instruments.DetoxInstrumentsManager;
 import com.wix.detox.reactnative.ReactNativeExtension;
 import com.wix.detox.systeminfo.Environment;
 import com.wix.invoke.MethodInvocation;
@@ -27,6 +28,7 @@ class DetoxManager implements WebSocketClient.ActionHandler {
 
     private final static String DETOX_SERVER_ARG_KEY = "detoxServer";
     private final static String DETOX_SESSION_ID_ARG_KEY = "detoxSessionId";
+    private final static String DETOX_RECORDING_PATH = "recordingPath";
 
     private String detoxServerUrl;
     private String detoxSessionId;
@@ -39,6 +41,7 @@ class DetoxManager implements WebSocketClient.ActionHandler {
     private ReadyActionHandler readyActionHandler = null;
 
     private Context reactNativeHostHolder;
+    private DetoxInstrumentsManager instrumentsManager;
 
     DetoxManager(@NonNull Context context) {
         this.reactNativeHostHolder = context;
@@ -57,6 +60,13 @@ class DetoxManager implements WebSocketClient.ActionHandler {
             stop();
             return;
         }
+        if (DetoxInstrumentsManager.supports()) {
+            final String recordingPath = arguments.getString(DETOX_RECORDING_PATH);
+            if (recordingPath != null) {
+                instrumentsManager = new DetoxInstrumentsManager(reactNativeHostHolder);
+                instrumentsManager.startRecordingAtLocalPath(recordingPath);
+            }
+        }
 
         Log.i(LOG_TAG, "DetoxServerUrl: " + detoxServerUrl);
         Log.i(LOG_TAG, "DetoxSessionId: " + detoxSessionId);
@@ -145,5 +155,13 @@ class DetoxManager implements WebSocketClient.ActionHandler {
                 return null;
             }
         }));
+
+        if (DetoxInstrumentsManager.supports()) {
+            if (instrumentsManager == null) {
+                instrumentsManager = new DetoxInstrumentsManager(reactNativeHostHolder);
+            }
+            actionHandlers.put("setRecordingState", new InstrumentsRecordingStateActionHandler(instrumentsManager, wsClient));
+            actionHandlers.put("event", new InstrumentsEventsActionsHandler(instrumentsManager, wsClient));
+        }
     }
 }
diff --git a/detox/android/detox/src/main/java/com/wix/detox/instruments/DetoxInstrumentsException.java b/detox/android/detox/src/main/java/com/wix/detox/instruments/DetoxInstrumentsException.java
new file mode 100644
index 000000000..9b39f0f39
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/instruments/DetoxInstrumentsException.java
@@ -0,0 +1,11 @@
+package com.wix.detox.instruments;
+
+public class DetoxInstrumentsException extends RuntimeException {
+    public DetoxInstrumentsException(String message) {
+        super(message);
+    }
+
+    public DetoxInstrumentsException(Throwable cause) {
+        super(cause);
+    }
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/instruments/DetoxInstrumentsManager.java b/detox/android/detox/src/main/java/com/wix/detox/instruments/DetoxInstrumentsManager.java
new file mode 100644
index 000000000..22500ee65
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/instruments/DetoxInstrumentsManager.java
@@ -0,0 +1,79 @@
+package com.wix.detox.instruments;
+
+import android.content.Context;
+
+import com.wix.detox.instruments.reflected.InstrumentsReflected;
+
+import java.io.File;
+
+public class DetoxInstrumentsManager {
+
+    private final Context context;
+    private final Instruments instruments;
+    private InstrumentsRecording recording;
+
+    public DetoxInstrumentsManager(Context context) {
+        this(context, getDefaultInstruments());
+    }
+
+    public DetoxInstrumentsManager(Context context, Instruments instruments) {
+        this.context = context;
+        this.instruments = instruments;
+    }
+
+    public void startRecordingAtLocalPath(String path) {
+        if (!instruments.installed()) {
+            return;
+        }
+
+        recording = instruments.startRecording(context, true, 500L, new File(path), false);
+    }
+
+    public void stopRecording() {
+        if (recording != null) {
+            recording.stop();
+            recording = null;
+        }
+    }
+
+    public void eventBeginInterval(
+            String category,
+            String name,
+            String id,
+            String additionalInfo
+    ) {
+        if (recording != null) {
+            recording.eventBeginInterval(category, name, id, additionalInfo);
+        }
+    }
+
+    public void eventEndInterval(
+            String id,
+            String eventStatus,
+            String additionalInfo
+    ) {
+        if (recording != null) {
+            recording.eventEndInterval(id, eventStatus, additionalInfo);
+        }
+    }
+
+    public void eventMark(
+            String category,
+            String name,
+            String id,
+            String eventStatus,
+            String additionalInfo
+    ) {
+        if (recording != null) {
+            recording.eventMark(category, name, id, eventStatus, additionalInfo);
+        }
+    }
+
+    public static boolean supports() {
+        return getDefaultInstruments().installed();
+    }
+
+    private static Instruments getDefaultInstruments() {
+        return InstrumentsReflected.getInstance();
+    }
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/instruments/Instruments.java b/detox/android/detox/src/main/java/com/wix/detox/instruments/Instruments.java
new file mode 100644
index 000000000..3c6190548
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/instruments/Instruments.java
@@ -0,0 +1,17 @@
+package com.wix.detox.instruments;
+
+import android.content.Context;
+
+import java.io.File;
+
+public interface Instruments {
+    boolean installed();
+
+    InstrumentsRecording startRecording(
+            Context context,
+            boolean recordPerformance,
+            long samplingInterval,
+            File recordingFile,
+            boolean recordReactNativeTimersAsEvents
+    );
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/instruments/InstrumentsRecording.java b/detox/android/detox/src/main/java/com/wix/detox/instruments/InstrumentsRecording.java
new file mode 100644
index 000000000..4a59402af
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/instruments/InstrumentsRecording.java
@@ -0,0 +1,27 @@
+package com.wix.detox.instruments;
+
+
+public interface InstrumentsRecording {
+    void stop();
+
+    void eventBeginInterval(
+            String category,
+            String name,
+            String id,
+            String additionalInfo
+    );
+
+    void eventEndInterval(
+            String id,
+            String eventStatus,
+            String additionalInfo
+    );
+
+    void eventMark(
+            String category,
+            String name,
+            String id,
+            String eventStatus,
+            String additionalInfo
+    );
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/instruments/reflected/InstrumentsRecordingReflected.java b/detox/android/detox/src/main/java/com/wix/detox/instruments/reflected/InstrumentsRecordingReflected.java
new file mode 100644
index 000000000..f3f3c8cab
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/instruments/reflected/InstrumentsRecordingReflected.java
@@ -0,0 +1,109 @@
+package com.wix.detox.instruments.reflected;
+
+import com.wix.detox.instruments.DetoxInstrumentsException;
+import com.wix.detox.instruments.InstrumentsRecording;
+
+import java.lang.reflect.Method;
+
+public class InstrumentsRecordingReflected implements InstrumentsRecording {
+    private static Method methodStopRecording;
+    private static Method methodEventBeginInterval;
+    private static Method methodEventEndInterval;
+    private static Method methodEventMark;
+
+    static {
+        try {
+            final String basePackageName = "com.wix.detoxprofiler";
+            final Class<?> profilerClass = Class.forName(basePackageName + ".DTXProfiler");
+
+            methodStopRecording = profilerClass.getDeclaredMethod("stopProfiling");
+            methodEventBeginInterval = profilerClass.getDeclaredMethod("eventBeginInterval",
+                    String.class,//category
+                    String.class,//name
+                    String.class,//id
+                    String.class,//additionalInfo
+                    String.class//stackTrace
+            );
+            methodEventEndInterval = profilerClass.getDeclaredMethod("eventEndInterval",
+                    String.class,//id
+                    String.class,//eventStatus
+                    String.class//additionalInfo
+            );
+            methodEventMark = profilerClass.getDeclaredMethod("eventMark",
+                    String.class,//category
+                    String.class,//name
+                    String.class,//id
+                    String.class,//eventStatus
+                    String.class//additionalInfo
+            );
+        } catch (Exception e) {
+            methodStopRecording = null;
+            methodEventBeginInterval = null;
+            methodEventEndInterval = null;
+            methodEventMark = null;
+        }
+    }
+
+    private final Object profilerInstance;
+
+    public InstrumentsRecordingReflected(Object profilerInstance) {
+        this.profilerInstance = profilerInstance;
+    }
+
+    @Override
+    public void stop() {
+        try {
+            methodStopRecording.invoke(profilerInstance);
+        } catch (Exception e) {
+            throw new DetoxInstrumentsException(e);
+        }
+    }
+
+    @Override
+    public void eventBeginInterval(
+            String category,
+            String name,
+            String id,
+            String additionalInfo
+    ) {
+        try {
+            methodEventBeginInterval.invoke(profilerInstance,
+                    category, name, id, additionalInfo, null
+            );
+        } catch (Exception e) {
+            throw new DetoxInstrumentsException(e);
+        }
+    }
+
+    @Override
+    public void eventEndInterval(
+            String id,
+            String eventStatus,
+            String additionalInfo
+    ) {
+        try {
+            methodEventEndInterval.invoke(profilerInstance,
+                    id, eventStatus, additionalInfo
+            );
+        } catch (Exception e) {
+            throw new DetoxInstrumentsException(e);
+        }
+    }
+
+    @Override
+    public void eventMark(
+            String category,
+            String name,
+            String id,
+            String eventStatus,
+            String additionalInfo
+    ) {
+        try {
+            methodEventMark.invoke(profilerInstance,
+                    category, name, id, eventStatus, additionalInfo
+            );
+        } catch (Exception e) {
+            throw new DetoxInstrumentsException(e);
+        }
+    }
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/instruments/reflected/InstrumentsReflected.java b/detox/android/detox/src/main/java/com/wix/detox/instruments/reflected/InstrumentsReflected.java
new file mode 100644
index 000000000..74b5a94a2
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/instruments/reflected/InstrumentsReflected.java
@@ -0,0 +1,80 @@
+package com.wix.detox.instruments.reflected;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.wix.detox.instruments.DetoxInstrumentsException;
+import com.wix.detox.instruments.Instruments;
+import com.wix.detox.instruments.InstrumentsRecording;
+
+import java.io.File;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+
+public class InstrumentsReflected implements Instruments {
+    private static final InstrumentsReflected instance = new InstrumentsReflected();
+
+    private static Constructor constructorDtxProfilingConfiguration;
+    private static Method methodGetInstanceOfProfiler;
+    private static Method methodStartRecording;
+    private static final boolean hasProfiler;
+
+    static {
+        try {
+            final String basePackageName = "com.wix.detoxprofiler";
+            final Class<?> profilerClass = Class.forName(basePackageName + ".DTXProfiler");
+            final Class<?> configurationClass = Class.forName(basePackageName + ".DTXProfilingConfiguration");
+
+            constructorDtxProfilingConfiguration = configurationClass.getConstructor(
+                    boolean.class,//recordPerformance
+                    long.class,//samplingIntervalMillis
+                    File.class,//recordingFile
+                    boolean.class//recordReactNativeTimersAsEvents
+            );
+            methodGetInstanceOfProfiler = profilerClass.getDeclaredMethod("getInstance", Context.class);
+            methodStartRecording = profilerClass.getDeclaredMethod("startProfiling", Context.class, configurationClass);
+        } catch (Exception e) {
+            Log.i("DetoxInstrumentsManager", "InstrumentsRecording not found", e);
+
+            constructorDtxProfilingConfiguration = null;
+            methodGetInstanceOfProfiler = null;
+            methodStartRecording = null;
+        }
+        hasProfiler = methodGetInstanceOfProfiler != null;
+    }
+
+    private InstrumentsReflected() {
+    }
+
+    @Override
+    public boolean installed() {
+        return hasProfiler;
+    }
+
+    @Override
+    public InstrumentsRecording startRecording(
+            Context context,
+            boolean recordPerformance,
+            long samplingInterval,
+            File recordingFile,
+            boolean recordReactNativeTimersAsEvents
+    ) {
+        try {
+            final Object configurationInstance = constructorDtxProfilingConfiguration.newInstance(
+                    recordPerformance,
+                    samplingInterval,
+                    recordingFile,
+                    recordReactNativeTimersAsEvents
+            );
+            final Object profilerInstance = methodGetInstanceOfProfiler.invoke(null, context);
+            methodStartRecording.invoke(profilerInstance, context, configurationInstance);
+            return new InstrumentsRecordingReflected(profilerInstance);
+        } catch (Exception e) {
+            throw new DetoxInstrumentsException(e);
+        }
+    }
+
+    public static Instruments getInstance() {
+        return instance;
+    }
+}
diff --git a/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js b/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.js
similarity index 63%
rename from detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js
rename to detox/src/artifacts/instruments/InstrumentsArtifactPlugin.js
index bd35f0e76..ad2cc34d2 100644
--- a/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js
+++ b/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.js
@@ -1,11 +1,17 @@
-const temporaryPath = require('../utils/temporaryPath');
 const WholeTestRecorderPlugin = require('../templates/plugin/WholeTestRecorderPlugin');
-const SimulatorInstrumentsRecording = require('./SimulatorInstrumentsRecording');
+const log = require('../../utils/logger').child({ __filename });
 
-class SimulatorInstrumentsPlugin extends WholeTestRecorderPlugin {
-  constructor({ api, client }) {
+class InstrumentsArtifactPlugin extends WholeTestRecorderPlugin {
+  constructor({ api }) {
     super({ api });
-    this.client = client;
+  }
+
+  async onBeforeLaunchApp(event) {
+    await super.onBeforeLaunchApp(event);
+
+    if (this.testRecording) {
+      event.launchArgs['recordingPath'] = this.testRecording.temporaryRecordingPath;
+    }
   }
 
   async onBeforeUninstallApp(event) {
@@ -29,18 +35,6 @@ class SimulatorInstrumentsPlugin extends WholeTestRecorderPlugin {
     }
   }
 
-  async onBeforeLaunchApp(event) {
-    await super.onBeforeLaunchApp(event);
-
-    if (process.env.DETOX_INSTRUMENTS_PATH) {
-      event.launchArgs['instrumentsPath'] = process.env.DETOX_INSTRUMENTS_PATH;
-    }
-
-    if (this.testRecording) {
-      event.launchArgs['recordingPath'] = this.testRecording.temporaryRecordingPath;
-    }
-  }
-
   async onLaunchApp(event) {
     await super.onLaunchApp(event);
 
@@ -49,18 +43,6 @@ class SimulatorInstrumentsPlugin extends WholeTestRecorderPlugin {
     }
   }
 
-  createTestRecording() {
-    return new SimulatorInstrumentsRecording({
-      pluginContext: this.context,
-      client: this.client,
-      temporaryRecordingPath: temporaryPath.for.dtxrec(),
-    });
-  }
-
-  async preparePathForTestArtifact(testSummary) {
-    return this.api.preparePathForArtifact('test.dtxrec', testSummary);
-  }
-
   static parseConfig(config) {
     switch (config) {
       case 'all':
@@ -78,4 +60,4 @@ class SimulatorInstrumentsPlugin extends WholeTestRecorderPlugin {
   }
 }
 
-module.exports = SimulatorInstrumentsPlugin;
+module.exports = InstrumentsArtifactPlugin;
diff --git a/detox/src/artifacts/instruments/InstrumentsArtifactRecording.js b/detox/src/artifacts/instruments/InstrumentsArtifactRecording.js
new file mode 100644
index 000000000..ef851f07f
--- /dev/null
+++ b/detox/src/artifacts/instruments/InstrumentsArtifactRecording.js
@@ -0,0 +1,36 @@
+const Artifact = require('../templates/artifact/Artifact');
+
+class InstrumentsArtifactRecording extends Artifact {
+  constructor({ client, temporaryRecordingPath }) {
+    super();
+
+    this._client = client;
+    this.temporaryRecordingPath = temporaryRecordingPath;
+  }
+
+  async doStart({ dry = false } = {}) {
+    if (dry) {
+      return; // nominal start, to preserve state change
+    }
+
+    if (!this._isClientConnected()) {
+      return;
+    }
+
+    await this._client.startInstrumentsRecording({
+      recordingPath: this.temporaryRecordingPath,
+    });
+  }
+
+  async doStop() {
+    if (this._isClientConnected()) {
+      await this._client.stopInstrumentsRecording();
+    }
+  }
+
+  _isClientConnected() {
+    return this._client.isConnected && !this._client.pandingAppCrash;
+  }
+}
+
+module.exports = InstrumentsArtifactRecording;
diff --git a/detox/src/artifacts/instruments/android/AndroidInstrumentsPlugin.js b/detox/src/artifacts/instruments/android/AndroidInstrumentsPlugin.js
new file mode 100644
index 000000000..4e1e27036
--- /dev/null
+++ b/detox/src/artifacts/instruments/android/AndroidInstrumentsPlugin.js
@@ -0,0 +1,31 @@
+const InstrumentsArtifactPlugin = require('../InstrumentsArtifactPlugin');
+const AndroidInstrumentsRecording = require('./AndroidInstrumentsRecording');
+
+class AndroidInstrumentsPlugin extends InstrumentsArtifactPlugin {
+  constructor({ api, adb, client, devicePathBuilder }) {
+    super({ api });
+
+    this.adb = adb;
+    this.client = client;
+    this.devicePathBuilder = devicePathBuilder;
+  }
+
+  async onBeforeLaunchApp(event) {
+    await super.onBeforeLaunchApp(event);
+  }
+
+  async preparePathForTestArtifact(testSummary) {
+    return this.api.preparePathForArtifact('test.dtxplain', testSummary);
+  }
+
+  createTestRecording() {
+    return new AndroidInstrumentsRecording({
+      adb: this.adb,
+      client: this.client,
+      deviceId: this.context.deviceId,
+      temporaryRecordingPath: this.devicePathBuilder.buildTemporaryArtifactPath('.dtxplain'),
+    });
+  }
+}
+
+module.exports = AndroidInstrumentsPlugin;
diff --git a/detox/src/artifacts/instruments/android/AndroidInstrumentsRecording.js b/detox/src/artifacts/instruments/android/AndroidInstrumentsRecording.js
new file mode 100644
index 000000000..b163e52a7
--- /dev/null
+++ b/detox/src/artifacts/instruments/android/AndroidInstrumentsRecording.js
@@ -0,0 +1,22 @@
+const log = require('../../../utils/logger').child({ __filename });
+const InstrumentsArtifactRecording = require('../InstrumentsArtifactRecording');
+
+class AndroidInstrumentsRecording extends InstrumentsArtifactRecording {
+  constructor({ adb, client, deviceId, temporaryRecordingPath }) {
+    super({ client, temporaryRecordingPath });
+    this.adb = adb;
+    this.deviceId = deviceId;
+  }
+
+  async doSave(artifactPath) {
+    await super.doSave(artifactPath);
+    await this.adb.pull(this.deviceId, this.temporaryRecordingPath, artifactPath);
+    await this.adb.rm(this.deviceId, this.temporaryRecordingPath, true);
+  }
+
+  async doDiscard() {
+    await this.adb.rm(this.deviceId, this.temporaryRecordingPath, true);
+  }
+}
+
+module.exports = AndroidInstrumentsRecording;
diff --git a/detox/src/artifacts/instruments/ios/SimulatorInstrumentsPlugin.js b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsPlugin.js
new file mode 100644
index 000000000..9366cfedb
--- /dev/null
+++ b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsPlugin.js
@@ -0,0 +1,33 @@
+const InstrumentsArtifactPlugin = require('../InstrumentsArtifactPlugin');
+const SimulatorInstrumentsRecording = require('./SimulatorInstrumentsRecording');
+const temporaryPath = require('../../utils/temporaryPath');
+
+class SimulatorInstrumentsPlugin extends InstrumentsArtifactPlugin {
+  constructor({ api, client }) {
+    super({ api });
+
+    this.client = client;
+  }
+
+  async onBeforeLaunchApp(event) {
+    await super.onBeforeLaunchApp(event);
+
+    if (process.env.DETOX_INSTRUMENTS_PATH) {
+      event.launchArgs['instrumentsPath'] = process.env.DETOX_INSTRUMENTS_PATH;
+    }
+  }
+
+  async preparePathForTestArtifact(testSummary) {
+    return this.api.preparePathForArtifact('test.dtxrec', testSummary);
+  }
+
+  createTestRecording() {
+    return new SimulatorInstrumentsRecording({
+      pluginContext: this.context,
+      client: this.client,
+      temporaryRecordingPath: temporaryPath.for.dtxrec(),
+    });
+  }
+}
+
+module.exports = SimulatorInstrumentsPlugin;
diff --git a/detox/src/artifacts/instruments/SimulatorInstrumentsRecording.js b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsRecording.js
similarity index 54%
rename from detox/src/artifacts/instruments/SimulatorInstrumentsRecording.js
rename to detox/src/artifacts/instruments/ios/SimulatorInstrumentsRecording.js
index db11ce133..b72d27685 100644
--- a/detox/src/artifacts/instruments/SimulatorInstrumentsRecording.js
+++ b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsRecording.js
@@ -1,39 +1,13 @@
 const _ = require('lodash');
-const log = require('../../utils/logger').child({ __filename });
-const Artifact = require('../templates/artifact/Artifact');
-const FileArtifact = require('../templates/artifact/FileArtifact');
+const log = require('../../../utils/logger').child({ __filename });
+const InstrumentsArtifactRecording = require('../InstrumentsArtifactRecording');
+const FileArtifact = require('../../templates/artifact/FileArtifact');
 
-class SimulatorInstrumentsRecording extends Artifact {
-  constructor({
-    pluginContext,
-    client,
-    temporaryRecordingPath,
-  }) {
-    super();
+class SimulatorInstrumentsRecording extends InstrumentsArtifactRecording {
+  constructor({ pluginContext, client, temporaryRecordingPath }) {
+    super({ client, temporaryRecordingPath });
 
     this._pluginContext = pluginContext;
-    this._client = client;
-    this.temporaryRecordingPath = temporaryRecordingPath;
-  }
-
-  async doStart({ dry = false } = {}) {
-    if (dry) {
-      return; // nominal start, to preserve state change
-    }
-
-    if (!this._isClientConnected()) {
-      return;
-    }
-
-    await this._client.startInstrumentsRecording({
-      recordingPath: this.temporaryRecordingPath,
-    });
-  }
-
-  async doStop() {
-    if (this._isClientConnected()) {
-      await this._client.stopInstrumentsRecording();
-    }
   }
 
   async doSave(artifactPath) {
@@ -47,8 +21,9 @@ class SimulatorInstrumentsRecording extends Artifact {
     await fs.remove(this.temporaryRecordingPath);
   }
 
+
   _isClientConnected() {
-    const isConnectedToDetoxServer = this._client.isConnected && !this._client.pandingAppCrash;
+    const isConnectedToDetoxServer = super._isClientConnected();
     const isAppRunning = this._pluginContext.bundleId;
 
     return Boolean(isConnectedToDetoxServer && isAppRunning);
diff --git a/detox/src/configuration.js b/detox/src/configuration.js
index 85982169e..8254f8772 100644
--- a/detox/src/configuration.js
+++ b/detox/src/configuration.js
@@ -6,7 +6,7 @@ const argparse = require('./utils/argparse');
 const getPort = require('get-port');
 const buildDefaultArtifactsRootDirpath = require('./artifacts/utils/buildDefaultArtifactsRootDirpath');
 
-const SimulatorInstrumentsPlugin = require('./artifacts/instruments/SimulatorInstrumentsPlugin');
+const InstrumentsArtifactPlugin = require('./artifacts/instruments/InstrumentsArtifactPlugin');
 const LogArtifactPlugin = require('./artifacts/log/LogArtifactPlugin');
 const ScreenshotArtifactPlugin = require('./artifacts/screenshot/ScreenshotArtifactPlugin');
 const VideoArtifactPlugin = require('./artifacts/video/VideoArtifactPlugin');
@@ -127,7 +127,7 @@ function composeArtifactsConfig({
 
   artifactsConfig.plugins = _.mapValues(artifactsConfig.plugins, (value, key) => {
     switch (key) {
-      case 'instruments': return SimulatorInstrumentsPlugin.parseConfig(value);
+      case 'instruments': return InstrumentsArtifactPlugin.parseConfig(value);
       case 'log': return LogArtifactPlugin.parseConfig(value);
       case 'screenshot': return ScreenshotArtifactPlugin.parseConfig(value);
       case 'video': return VideoArtifactPlugin.parseConfig(value);
diff --git a/detox/src/configurations.mock.js b/detox/src/configurations.mock.js
index e3e624924..7353976b9 100644
--- a/detox/src/configurations.mock.js
+++ b/detox/src/configurations.mock.js
@@ -1,4 +1,4 @@
-const SimulatorInstrumentsPlugin = require('./artifacts/instruments/SimulatorInstrumentsPlugin');
+const InstrumentsArtifactPlugin = require('./artifacts/instruments/InstrumentsArtifactPlugin');
 const LogArtifactPlugin = require('./artifacts/log/LogArtifactPlugin');
 const ScreenshotArtifactPlugin = require('./artifacts/screenshot/ScreenshotArtifactPlugin');
 const VideoArtifactPlugin = require('./artifacts/video/VideoArtifactPlugin');
@@ -29,7 +29,7 @@ const pluginsDefaultsResolved = {
   log: LogArtifactPlugin.parseConfig('none'),
   screenshot: ScreenshotArtifactPlugin.parseConfig('manual'),
   video: VideoArtifactPlugin.parseConfig('none'),
-  instruments: SimulatorInstrumentsPlugin.parseConfig('none'),
+  instruments: InstrumentsArtifactPlugin.parseConfig('none'),
 };
 
 const pluginsFailingResolved = {
@@ -42,7 +42,7 @@ const pluginsAllResolved = {
   log: LogArtifactPlugin.parseConfig('all'),
   screenshot: ScreenshotArtifactPlugin.parseConfig('all'),
   video: VideoArtifactPlugin.parseConfig('all'),
-  instruments: SimulatorInstrumentsPlugin.parseConfig('all'),
+  instruments: InstrumentsArtifactPlugin.parseConfig('all'),
 };
 
 const validOneDeviceNoSession = {
diff --git a/detox/src/devices/drivers/AndroidDriver.js b/detox/src/devices/drivers/AndroidDriver.js
index 29d82b555..9b36c6e69 100644
--- a/detox/src/devices/drivers/AndroidDriver.js
+++ b/detox/src/devices/drivers/AndroidDriver.js
@@ -13,6 +13,7 @@ const DeviceDriverBase = require('./DeviceDriverBase');
 const DetoxApi = require('../../android/espressoapi/Detox');
 const EspressoDetoxApi = require('../../android/espressoapi/EspressoDetox');
 const UiDeviceProxy = require('../../android/espressoapi/UiDeviceProxy');
+const AndroidInstrumentsPlugin = require('../../artifacts/instruments/android/AndroidInstrumentsPlugin');
 const ADBLogcatPlugin = require('../../artifacts/log/android/ADBLogcatPlugin');
 const ADBScreencapPlugin = require('../../artifacts/screenshot/ADBScreencapPlugin');
 const ADBScreenrecorderPlugin = require('../../artifacts/video/ADBScreenrecorderPlugin');
@@ -42,9 +43,10 @@ class AndroidDriver extends DeviceDriverBase {
   }
 
   declareArtifactPlugins() {
-    const { adb, devicePathBuilder } = this;
+    const { adb, client, devicePathBuilder } = this;
 
     return {
+      instruments: (api) => new AndroidInstrumentsPlugin({ api, adb, client, devicePathBuilder }),
       log: (api) => new ADBLogcatPlugin({ api, adb, devicePathBuilder }),
       screenshot: (api) => new ADBScreencapPlugin({ api, adb, devicePathBuilder }),
       video: (api) => new ADBScreenrecorderPlugin({ api, adb, devicePathBuilder }),
diff --git a/detox/src/devices/drivers/IosDriver.js b/detox/src/devices/drivers/IosDriver.js
index cc9144354..480e67bf6 100644
--- a/detox/src/devices/drivers/IosDriver.js
+++ b/detox/src/devices/drivers/IosDriver.js
@@ -12,7 +12,7 @@ const AppleSimUtils = require('../ios/AppleSimUtils');
 const SimulatorLogPlugin = require('../../artifacts/log/ios/SimulatorLogPlugin');
 const SimulatorScreenshotPlugin = require('../../artifacts/screenshot/SimulatorScreenshotPlugin');
 const SimulatorRecordVideoPlugin = require('../../artifacts/video/SimulatorRecordVideoPlugin');
-const SimulatorInstrumentsPlugin = require('../../artifacts/instruments/SimulatorInstrumentsPlugin');
+const SimulatorInstrumentsPlugin = require('../../artifacts/instruments/ios/SimulatorInstrumentsPlugin');
 const IosExpect = require('../../ios/expect');
 
 class IosDriver extends DeviceDriverBase {
@@ -24,8 +24,7 @@ class IosDriver extends DeviceDriverBase {
   }
 
   declareArtifactPlugins() {
-    const appleSimUtils = this.applesimutils;
-    const client = this.client;
+    const { appleSimUtils, client } = this;
 
     return {
       instruments: (api) => new SimulatorInstrumentsPlugin({ api, client }),


Test patch:
diff --git a/detox/android/detox/src/test/java/com/wix/detox/DetoxActionHandlersSpec.kt b/detox/android/detox/src/test/java/com/wix/detox/DetoxActionHandlersSpec.kt
index a96d190ec..e8291b3d5 100644
--- a/detox/android/detox/src/test/java/com/wix/detox/DetoxActionHandlersSpec.kt
+++ b/detox/android/detox/src/test/java/com/wix/detox/DetoxActionHandlersSpec.kt
@@ -3,14 +3,18 @@ package com.wix.detox
 import android.content.Context
 import com.nhaarman.mockitokotlin2.*
 import com.wix.detox.UTHelpers.yieldToOtherThreads
+import com.wix.detox.instruments.DetoxInstrumentsException
+import com.wix.detox.instruments.DetoxInstrumentsManager
 import com.wix.invoke.MethodInvocation
+import org.assertj.core.api.Assertions
+import org.json.JSONObject
 import org.spekframework.spek2.Spek
 import org.spekframework.spek2.style.specification.describe
 import java.lang.reflect.InvocationTargetException
 import java.util.*
 import java.util.concurrent.Executors
 
-object DetoxActionHandlersSpec: Spek({
+object DetoxActionHandlersSpec : Spek({
     describe("Action handlers") {
         val params = "{\"mock\": \"params\"}"
         val messageId = 666L
@@ -142,5 +146,135 @@ object DetoxActionHandlersSpec: Spek({
                         eq(messageId))
             }
         }
+
+        describe("InstrumentsRecording recording state actions") {
+            lateinit var instrumentsManager: DetoxInstrumentsManager
+
+            fun uut() = InstrumentsRecordingStateActionHandler(instrumentsManager, wsClient)
+
+            beforeEachTest {
+                instrumentsManager = mock()
+            }
+
+            it("should start recording with path") {
+                uut().handle("{\"recordingPath\":\"/MockPath\"}", messageId)
+                verify(instrumentsManager).startRecordingAtLocalPath(eq("/MockPath"))
+            }
+
+            it("should stop recording without path") {
+                uut().handle("{\"recordingPath\":null}", messageId)
+                verify(instrumentsManager).stopRecording()
+            }
+
+            it("should reply with a 'done' ACK on set state finish") {
+                uut().handle(params, messageId)
+                verify(wsClient).sendAction(eq("setRecordingStateDone"), any(), eq(messageId))
+            }
+        }
+
+        describe("InstrumentsRecording events actions") {
+            lateinit var instrumentsManager: DetoxInstrumentsManager
+            val mockCategory = "MockCategory"
+            val mockName = "MockName"
+            val mockId = "MockId"
+            val mockAdditionalInfo = "MockAdditionalInfo"
+            val mockStatus = "MockStatus"
+
+            fun uut() = InstrumentsEventsActionsHandler(instrumentsManager, wsClient)
+
+            beforeEachTest {
+                instrumentsManager = mock()
+            }
+
+            describe("begin interval") {
+                val json = with(JSONObject()) {
+                    put("category", mockCategory)
+                    put("name", mockName)
+                    put("id", mockId)
+                    put("additionalInfo", mockAdditionalInfo)
+                    put("action", "begin")
+                }.toString()
+
+                it("should invoke instrumentation") {
+                    uut().handle(json, messageId)
+                    verify(instrumentsManager).eventBeginInterval(
+                            eq(mockCategory),
+                            eq(mockName),
+                            eq(mockId),
+                            eq(mockAdditionalInfo)
+                    )
+                }
+
+                it("should reply with a 'done' ACK") {
+                    uut().handle(json, messageId)
+                    verify(wsClient).sendAction(eq("eventDone"), any(), eq(messageId))
+                }
+            }
+
+            describe("end interval") {
+                val json = with(JSONObject()) {
+                    put("id", mockId)
+                    put("status", mockStatus)
+                    put("additionalInfo", mockAdditionalInfo)
+                    put("action", "end")
+                }.toString()
+
+                it("should invoke instrumentation") {
+                    uut().handle(json, messageId)
+                    verify(instrumentsManager).eventEndInterval(
+                            eq(mockId),
+                            eq(mockStatus),
+                            eq(mockAdditionalInfo)
+                    )
+                }
+
+                it("should reply with a 'done' ACK") {
+                    uut().handle(json, messageId)
+                    verify(wsClient).sendAction(eq("eventDone"), any(), eq(messageId))
+                }
+            }
+
+            describe("mark") {
+                val json = with(JSONObject()) {
+                    put("category", mockCategory)
+                    put("name", mockName)
+                    put("id", mockId)
+                    put("status", mockStatus)
+                    put("additionalInfo", mockAdditionalInfo)
+                    put("action", "mark")
+                }.toString()
+
+                it("should invoke instrumentation") {
+                    uut().handle(json, messageId)
+                    verify(instrumentsManager).eventMark(
+                            eq(mockCategory),
+                            eq(mockName),
+                            eq(mockId),
+                            eq(mockStatus),
+                            eq(mockAdditionalInfo)
+                    )
+                }
+
+                it("should reply with a 'done' ACK") {
+                    uut().handle(json, messageId)
+                    verify(wsClient).sendAction(eq("eventDone"), any(), eq(messageId))
+                }
+            }
+
+            it("wrong event action") {
+                val json = with(JSONObject()) {
+                    put("action", "wrong")
+                }.toString()
+
+                var err: Exception? = null
+                try {
+                    uut().handle(json, messageId)
+                } catch (e: DetoxInstrumentsException) {
+                    err = e
+                }
+                Assertions.assertThat(err).isNotNull()
+                Assertions.assertThat(err).hasMessage("Invalid action")
+            }
+        }
     }
 })
diff --git a/detox/android/detox/src/test/java/com/wix/detox/instruments/DetoxInstrumentsManagerSpec.kt b/detox/android/detox/src/test/java/com/wix/detox/instruments/DetoxInstrumentsManagerSpec.kt
new file mode 100644
index 000000000..31f5316f2
--- /dev/null
+++ b/detox/android/detox/src/test/java/com/wix/detox/instruments/DetoxInstrumentsManagerSpec.kt
@@ -0,0 +1,130 @@
+package com.wix.detox.instruments
+
+import android.content.Context
+import com.nhaarman.mockitokotlin2.*
+import com.wix.detox.TestEngineFacade
+import com.wix.detox.UTHelpers.yieldToOtherThreads
+import com.wix.detox.instruments.DetoxInstrumentsManager
+import com.wix.invoke.MethodInvocation
+import org.json.JSONObject
+import org.spekframework.spek2.Spek
+import org.spekframework.spek2.style.specification.describe
+import java.io.File
+import java.lang.reflect.InvocationTargetException
+import java.util.*
+import java.util.concurrent.Executors
+
+object DetoxInstrumentsManagerSpec : Spek({
+    describe("Instruments manager") {
+        lateinit var appContext: Context
+        lateinit var instruments: Instruments
+        lateinit var instrumentsManager: DetoxInstrumentsManager
+
+        val mockCategory = "MockCategory"
+        val mockName = "MockName"
+        val mockId = "MockId"
+        val mockAdditionalInfo = "MockAdditionalInfo"
+        val mockStatus = "MockStatus"
+        val mockPath = "/mock"
+
+        beforeEachTest {
+            appContext = mock()
+            instruments = mock()
+            instrumentsManager = DetoxInstrumentsManager(appContext, instruments)
+        }
+
+        it("should start recording when instruments installed with default params") {
+            whenever(instruments.installed()).thenReturn(true)
+            instrumentsManager.startRecordingAtLocalPath(mockPath)
+
+            verify(instruments).installed()
+            verify(instruments).startRecording(appContext, true, 500L, File(mockPath), false)
+        }
+
+        describe("proxy events") {
+            lateinit var recording: InstrumentsRecording
+
+            beforeEachTest {
+                recording = mock()
+                whenever(instruments.installed()).thenReturn(true)
+                whenever(instruments.startRecording(any(), any(), any(), any(), any())).thenReturn(recording)
+            }
+
+            it("skipping without started recording") {
+                instrumentsManager.eventBeginInterval(mockCategory, mockName, mockId, mockAdditionalInfo)
+                instrumentsManager.eventEndInterval(mockId, mockStatus, mockAdditionalInfo)
+                instrumentsManager.eventMark(mockCategory, mockName, mockId, mockStatus, mockAdditionalInfo)
+
+                verify(recording, never()).eventBeginInterval(any(), any(), any(), any())
+                verify(recording, never()).eventEndInterval(any(), any(), any())
+                verify(recording, never()).eventMark(any(), any(), any(), any(), any())
+            }
+
+            it("passing within started recording") {
+                instrumentsManager.startRecordingAtLocalPath(mockPath)
+                instrumentsManager.eventBeginInterval(mockCategory, mockName, mockId, mockAdditionalInfo)
+                instrumentsManager.eventEndInterval(mockId, mockStatus, mockAdditionalInfo)
+                instrumentsManager.eventMark(mockCategory, mockName, mockId, mockStatus, mockAdditionalInfo)
+
+                verify(recording).eventBeginInterval(any(), any(), any(), any())
+                verify(recording).eventEndInterval(any(), any(), any())
+                verify(recording).eventMark(any(), any(), any(), any(), any())
+            }
+
+            it("skipping with started recording which was stopped") {
+                instrumentsManager.startRecordingAtLocalPath(mockPath)
+                instrumentsManager.stopRecording()
+                instrumentsManager.eventBeginInterval(mockCategory, mockName, mockId, mockAdditionalInfo)
+                instrumentsManager.eventEndInterval(mockId, mockStatus, mockAdditionalInfo)
+                instrumentsManager.eventMark(mockCategory, mockName, mockId, mockStatus, mockAdditionalInfo)
+
+                verify(recording, never()).eventBeginInterval(any(), any(), any(), any())
+                verify(recording, never()).eventEndInterval(any(), any(), any())
+                verify(recording, never()).eventMark(any(), any(), any(), any(), any())
+            }
+        }
+
+        it("should not start recording when instruments not installed") {
+            whenever(instruments.installed()).thenReturn(false)
+            instrumentsManager.startRecordingAtLocalPath(mockPath)
+
+            verify(instruments).installed()
+            verify(instruments, never()).startRecording(any(), any(), any(), any(), any())
+        }
+
+        describe("recording") {
+            lateinit var recording: InstrumentsRecording
+
+            beforeEachTest {
+                recording = mock()
+
+                whenever(instruments.installed()).thenReturn(true)
+                whenever(
+                        instruments.startRecording(any(), any(), any(), any(), any())
+                ).thenReturn(recording)
+
+                instrumentsManager.startRecordingAtLocalPath(mockPath)
+            }
+
+            it("should begin event interval") {
+                instrumentsManager.eventBeginInterval(mockCategory, mockName, mockId, mockAdditionalInfo)
+                verify(recording).eventBeginInterval(mockCategory, mockName, mockId, mockAdditionalInfo)
+            }
+
+            it("should end event interval") {
+                instrumentsManager.eventEndInterval(mockId, mockStatus, mockAdditionalInfo)
+                verify(recording).eventEndInterval(mockId, mockStatus, mockAdditionalInfo)
+            }
+
+            it("should mark event") {
+                instrumentsManager.eventMark(mockCategory, mockName, mockId, mockStatus, mockAdditionalInfo)
+                verify(recording).eventMark(mockCategory, mockName, mockId, mockStatus, mockAdditionalInfo)
+            }
+
+            it("should stop") {
+                instrumentsManager.stopRecording()
+                verify(recording).stop()
+            }
+        }
+    }
+})
diff --git a/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.test.js b/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.test.js
similarity index 80%
rename from detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.test.js
rename to detox/src/artifacts/instruments/InstrumentsArtifactPlugin.test.js
index b42164285..a73061406 100644
--- a/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.test.js
+++ b/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.test.js
@@ -1,8 +1,8 @@
-const SimulatorInstrumentsPlugin = require('./SimulatorInstrumentsPlugin');
+const InstrumentsArtifactPlugin = require('./InstrumentsArtifactPlugin');
 
-describe('SimulatorInstrumentsPlugin', () => {
+describe('InstrumentsArtifactPlugin', () => {
     describe('static parseConfig(config)', () => {
-        const parseConfig = SimulatorInstrumentsPlugin.parseConfig;
+        const parseConfig = InstrumentsArtifactPlugin.parseConfig;
 
         const ENABLE_MODES = ['all'].map(x => [x]);
         const DISABLE_MODES = ['none', 'manual', 'failing', { enabled: true }].map(x => [x]);
diff --git a/detox/src/configuration.test.js b/detox/src/configuration.test.js
index 867279901..39ea59cbd 100644
--- a/detox/src/configuration.test.js
+++ b/detox/src/configuration.test.js
@@ -1,7 +1,6 @@
 const _ = require('lodash');
 const path = require('path');
 const schemes = require('./configurations.mock');
-const SimulatorInstrumentsPlugin = require('./artifacts/instruments/SimulatorInstrumentsPlugin');
 const LogArtifactPlugin = require('./artifacts/log/LogArtifactPlugin');
 const ScreenshotArtifactPlugin = require('./artifacts/screenshot/ScreenshotArtifactPlugin');
 const VideoArtifactPlugin = require('./artifacts/video/VideoArtifactPlugin');