Repository: wix/Detox
Pull request #2077 (2020-05-24 09:16:34)

Disable Android animations for all emulators
- [x] This is a small change 
- [ ] This change has been discussed in issue #<?> and the solution has been agreed upon with maintainers.

---

**Description:**
Addressing this has long-overdued, and its been recently incentivised for upon coming across related problems in internal apps. After making numerous futile attempts at creating an idling-resource that would fill in the missing gap<sup>*</sup>, it is now clear that the only option is to go with Google's recommendation and turn off animations across the board.

---
<sup>* System components such as `RenderThread` and the centralized (though thread-based) `AnimationHandler` (method `getAnimationCount()`) are all hidden and inaccessible even via reflection on actual devices.</sup>


Patch:
diff --git a/detox/src/devices/drivers/android/EmulatorDriver.js b/detox/src/devices/drivers/android/EmulatorDriver.js
index b23367882..f44aa169d 100644
--- a/detox/src/devices/drivers/android/EmulatorDriver.js
+++ b/detox/src/devices/drivers/android/EmulatorDriver.js
@@ -57,6 +57,7 @@ class EmulatorDriver extends AndroidDriver {
     await this._boot(avdName, adbName);
 
     await this.adb.apiLevel(adbName);
+    await this.adb.disableAndroidAnimations(adbName);
     await this.adb.unlockScreen(adbName);
 
     this._name = `${adbName} (${avdName})`;
diff --git a/detox/src/devices/drivers/android/tools/ADB.js b/detox/src/devices/drivers/android/tools/ADB.js
index c099d9513..7a7536292 100644
--- a/detox/src/devices/drivers/android/tools/ADB.js
+++ b/detox/src/devices/drivers/android/tools/ADB.js
@@ -157,6 +157,12 @@ class ADB {
     return lvl;
   }
 
+  async disableAndroidAnimations(deviceId) {
+    await this.shell(deviceId, `settings put global animator_duration_scale 0`);
+    await this.shell(deviceId, `settings put global window_animation_scale 0`);
+    await this.shell(deviceId, `settings put global transition_animation_scale 0`);
+  }
+
   async screencap(deviceId, path) {
     await this.shell(deviceId, `screencap ${path}`);
   }


Test patch:
diff --git a/detox/src/devices/drivers/android/tools/ADB.test.js b/detox/src/devices/drivers/android/tools/ADB.test.js
index 943c1129e..5cdd821a3 100644
--- a/detox/src/devices/drivers/android/tools/ADB.test.js
+++ b/detox/src/devices/drivers/android/tools/ADB.test.js
@@ -307,4 +307,21 @@ describe('ADB', () => {
     expect(adb.shell).toBeCalledWith('aDeviceId', 'pm list instrumentation');
     expect(result).toEqual(expectedRunner);
   });
+
+  describe('animation disabling', () => {
+    it('should disable animator (e.g. ObjectAnimator) animations', async () => {
+      await adb.disableAndroidAnimations();
+      expect(exec).toHaveBeenCalledWith(`"${adbBinPath}"  shell "settings put global animator_duration_scale 0"`, undefined, undefined, 1);
+    });
+
+    it('should disable window animations', async () => {
+      await adb.disableAndroidAnimations();
+      expect(exec).toHaveBeenCalledWith(`"${adbBinPath}"  shell "settings put global window_animation_scale 0"`, undefined, undefined, 1);
+    });
+
+    it('should disable transition (e.g. activity launch) animations', async () => {
+      await adb.disableAndroidAnimations();
+      expect(exec).toHaveBeenCalledWith(`"${adbBinPath}"  shell "settings put global transition_animation_scale 0"`, undefined, undefined, 1);
+    });
+  });
 });
diff --git a/detox/test/android/app/src/main/java/com/example/AnimationViewManager.java b/detox/test/android/app/src/main/java/com/example/AnimationViewManager.java
new file mode 100644
index 000000000..79c809161
--- /dev/null
+++ b/detox/test/android/app/src/main/java/com/example/AnimationViewManager.java
@@ -0,0 +1,50 @@
+package com.example;
+
+import android.animation.Animator;
+import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import com.facebook.react.uimanager.SimpleViewManager;
+import com.facebook.react.uimanager.ThemedReactContext;
+
+public class AnimationViewManager extends SimpleViewManager<View> {
+
+    @Override
+    public String getName() {
+        return "DetoxNativeAnimatingView";
+    }
+
+    @Override
+    protected View createViewInstance(ThemedReactContext reactContext) {
+        final ViewGroup rootView = (ViewGroup) LayoutInflater.from(reactContext).inflate(R.layout.animation_test, null, false);
+        final ProgressBar progressBar = rootView.findViewById(R.id.animationTest_progressBar);
+        final TextView statusText = rootView.findViewById(R.id.animationTest_statusText);
+
+        final ValueAnimator animator = ObjectAnimator.ofInt(0, 100);
+        animator.setDuration(5000);
+        animator.addUpdateListener(animation -> {
+            Integer value = (Integer) animation.getAnimatedValue();
+            progressBar.setProgress(value);
+        });
+        animator.addListener(new AnimatorListenerStub() {
+            @Override
+            public void onAnimationEnd(Animator animation) {
+                statusText.setVisibility(View.VISIBLE);
+            }
+        });
+
+        final Button button = rootView.findViewById(R.id.animationTest_button);
+        button.setOnClickListener(view -> {
+            button.setEnabled(false);
+            animator.start();
+        });
+
+        return rootView;
+    }
+}
diff --git a/detox/test/android/app/src/main/java/com/example/AnimatorListenerStub.java b/detox/test/android/app/src/main/java/com/example/AnimatorListenerStub.java
new file mode 100644
index 000000000..7ffec685c
--- /dev/null
+++ b/detox/test/android/app/src/main/java/com/example/AnimatorListenerStub.java
@@ -0,0 +1,21 @@
+package com.example;
+
+import android.animation.Animator;
+
+public class AnimatorListenerStub implements Animator.AnimatorListener {
+    @Override
+    public void onAnimationStart(Animator animation) {
+    }
+
+    @Override
+    public void onAnimationEnd(Animator animation) {
+    }
+
+    @Override
+    public void onAnimationCancel(Animator animation) {
+    }
+
+    @Override
+    public void onAnimationRepeat(Animator animation) {
+    }
+}
diff --git a/detox/test/android/app/src/main/java/com/example/NativeModulePackage.java b/detox/test/android/app/src/main/java/com/example/NativeModulePackage.java
index 83bc447bf..953686e03 100644
--- a/detox/test/android/app/src/main/java/com/example/NativeModulePackage.java
+++ b/detox/test/android/app/src/main/java/com/example/NativeModulePackage.java
@@ -14,17 +14,19 @@ public class NativeModulePackage implements ReactPackage {
 
     @Override
     public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
-        return Arrays.<NativeModule>asList(
+        return Arrays.asList(
                 new com.example.NativeModule(reactContext)
         );
     }
 
-    public List<Class<? extends JavaScriptModule>> createJSModules() {
-        return Collections.emptyList();
-    }
-
     @Override
     public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
+        return Arrays.asList(
+                new AnimationViewManager()
+        );
+    }
+
+    public List<Class<? extends JavaScriptModule>> createJSModules() {
         return Collections.emptyList();
     }
-}
\ No newline at end of file
+}
diff --git a/detox/test/android/app/src/main/res/layout/animation_test.xml b/detox/test/android/app/src/main/res/layout/animation_test.xml
new file mode 100644
index 000000000..41598725b
--- /dev/null
+++ b/detox/test/android/app/src/main/res/layout/animation_test.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:gravity="center"
+    >
+
+    <Button
+        android:id="@+id/animationTest_button"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="Start"
+        android:tag="startButton"
+        android:layout_marginBottom="16dp"
+        />
+
+    <ProgressBar
+        android:id="@+id/animationTest_progressBar"
+        style="?android:attr/progressBarStyleHorizontal"
+        android:layout_width="match_parent"
+        android:layout_height="32dp"
+        android:layout_marginHorizontal="16dp"
+        android:indeterminate="false"
+        android:max="100"
+        android:layout_marginBottom="16dp"
+        />
+
+    <TextView
+        android:id="@+id/animationTest_statusText"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="Animation Complete"
+        android:visibility="invisible"
+        />
+
+</LinearLayout>
diff --git a/detox/test/e2e/12.animations.test.js b/detox/test/e2e/12.animations.test.js
index 15dacabb5..147d1bd91 100644
--- a/detox/test/e2e/12.animations.test.js
+++ b/detox/test/e2e/12.animations.test.js
@@ -1,9 +1,9 @@
 let _ = require('lodash');
 
-describe('Animations', () => {
+describe('React-Native Animations', () => {
   beforeEach(async () => {
     await device.reloadReactNative();
-    await element(by.text('Animations')).tap();
+    await element(by.text('RN Animations')).tap();
   });
 
   async function _startTest(driver, options = {}) {
@@ -58,3 +58,13 @@ describe('Animations', () => {
     });
   });
 });
+
+describe(':android: Native animations', () => {
+  it('should expect a native android-animator animation to be short-circuited / fully-completed', async () => {
+    await device.reloadReactNative();
+    await element(by.text('Native Animation')).tap();
+
+    await element(by.id('startButton')).tap();
+    await expect(element(by.text('Animation Complete'))).toBeVisible();
+  });
+});
diff --git a/detox/test/src/Screens/NativeAnimationsScreen.js b/detox/test/src/Screens/NativeAnimationsScreen.js
new file mode 100644
index 000000000..ca861848f
--- /dev/null
+++ b/detox/test/src/Screens/NativeAnimationsScreen.js
@@ -0,0 +1,16 @@
+import React, { Component } from 'react';
+import { requireNativeComponent, View, Text } from 'react-native';
+
+const NativeAnimatingView = requireNativeComponent('DetoxNativeAnimatingView');
+
+class NativeAnimationsScreen extends Component {
+  render() {
+    return (
+      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'stretch' }}>
+        <NativeAnimatingView style={{flex: 1}}/>
+      </View>
+    );
+  }
+}
+
+module.exports = NativeAnimationsScreen;
diff --git a/detox/test/src/Screens/index.js b/detox/test/src/Screens/index.js
index 3e42ae5fc..b6abf09fc 100644
--- a/detox/test/src/Screens/index.js
+++ b/detox/test/src/Screens/index.js
@@ -11,7 +11,8 @@ import TimeoutsScreen from './TimeoutsScreen';
 import Orientation from './Orientation';
 import Permissions from './Permissions';
 import NetworkScreen from './NetworkScreen';
-import AnimationsScreen from './AnimationsScreen';
+import RNAnimationsScreen from './AnimationsScreen';
+import NativeAnimationsScreen from './NativeAnimationsScreen';
 import LocationScreen from './LocationScreen';
 import ShakeScreen from './ShakeScreen';
 import DatePickerScreen from './DatePickerScreen';
@@ -34,7 +35,8 @@ export {
   Orientation,
   Permissions,
   NetworkScreen,
-  AnimationsScreen,
+  RNAnimationsScreen,
+  NativeAnimationsScreen,
   LocationScreen,
   ShakeScreen,
   DatePickerScreen,
diff --git a/detox/test/src/app.js b/detox/test/src/app.js
index 7673d568f..bbff26e19 100644
--- a/detox/test/src/app.js
+++ b/detox/test/src/app.js
@@ -105,12 +105,13 @@ class example extends Component {
           {this.renderScreenButton('Orientation', Screens.Orientation)}
           {this.renderScreenButton('Permissions', Screens.Permissions)}
           {this.renderScreenButton('Network', Screens.NetworkScreen)}
-          {this.renderScreenButton('Animations', Screens.AnimationsScreen)}
+          {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).
@@ -139,6 +140,16 @@ class example extends Component {
     );
   }
 
+  renderAnimationScreenButtons() {
+    return (
+      <View style={{flexDirection: 'row', justifyContent: 'center'}}>
+        {this.renderScreenButton('RN Animations', Screens.RNAnimationsScreen)}
+        {isAndroid && <Text style={{width: 10}}> | </Text>}
+        {this.renderScreenButton('Native Animation', Screens.NativeAnimationsScreen)}
+      </View>
+    );
+  }
+
   _onNotification(notification) {
     console.log('App@onNotification:', notification);
     this.setState({notification: notification.getAlert()});