chromium/base/test/android/javatests/src/org/chromium/base/test/util/TestAnimations.java

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.base.test.util;

import android.os.Build;
import android.os.Build.VERSION_CODES;
import android.os.IBinder;
import android.provider.Settings;

import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.test.InstrumentationRegistry;

import org.chromium.base.ContextUtils;
import org.chromium.base.JavaUtils;
import org.chromium.base.Log;

import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;
import java.util.Arrays;

/** Enables / disables window Android animations. */
public class TestAnimations {

    private static Method sSetAnimationScalesMethod;
    private static Method sGetAnimationScalesMethod;
    private static Object sWindowManagerObject;

    /** Enable animations for the test class / method (default is disabled). */
    @Target({ElementType.METHOD, ElementType.TYPE})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface EnableAnimations {}

    private static final String TAG = "TestAnimations";
    // Start as null because we cannot trust that animations were left enabled by the previous test.
    private static @Nullable Boolean sAnimationsEnabled;

    /** Enable / disable animations based on @EnableAnimations annotation. */
    public static void reset(Class<?> testClass, @Nullable Method testMethod) {
        boolean enable =
                testClass.getAnnotation(EnableAnimations.class) != null
                        || (testMethod != null
                                && testMethod.getAnnotation(EnableAnimations.class) != null);
        // TODO(agrieve): Delete legacy annotation.
        if (!enable && testMethod != null) {
            try {
                Class<Annotation> legacyAnnotation =
                        (Class<Annotation>)
                                Class.forName("org.chromium.ui.test.util.EnsureAnimationsOn");
                enable = testMethod.getAnnotation(legacyAnnotation) != null;
            } catch (ClassNotFoundException e) {
                // Class not included.
            }
        }

        setEnabled(enable);
    }

    /** Enable / disable animations. */
    public static void setEnabled(boolean value) {
        if (sAnimationsEnabled == null) {
            float curAnimationScale =
                    Settings.Global.getFloat(
                            ContextUtils.getApplicationContext().getContentResolver(),
                            Settings.Global.ANIMATOR_DURATION_SCALE,
                            1);
            if (curAnimationScale == 1) {
                sAnimationsEnabled = true;
            } else if (curAnimationScale == 0) {
                sAnimationsEnabled = false;
            } else {
                Log.i(TAG, "Animations scale was %s", curAnimationScale);
            }
        }
        if (sAnimationsEnabled != null && value == sAnimationsEnabled) {
            Log.i(TAG, "Animations are already %s.", value ? "enabled" : "disabled");
            return;
        }
        float scaleFactor = value ? 1.0f : 0.0f;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            setViaSupportedApi(scaleFactor);
        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            setViaShell(scaleFactor);
        } else {
            try {
                setViaReflection(scaleFactor);
            } catch (Exception e) {
                // If this fails, add required manifest permission via:
                // deps += [ "//testing/android/instrumentation:test_runner_permissions_java" ]
                Log.i(TAG, "Could not disable animations.");
                return;
            }
        }
        sAnimationsEnabled = value;
        Log.i(TAG, "Animations are now %s.", value ? "enabled" : "disabled");
    }

    private static void executeShellCommand(String command) {
        try {
            InstrumentationRegistry.getInstrumentation()
                    .getUiAutomation()
                    .executeShellCommand(command)
                    .close();
        } catch (IOException e) {
            JavaUtils.throwUnchecked(e);
        }
    }

    @RequiresApi(VERSION_CODES.S)
    private static void setViaShell(float scaleFactor) {
        // Set animation scales through settings through shell commands in S+.
        String[] commands = {
            "settings put global animator_duration_scale " + scaleFactor,
            "settings put global transition_animation_scale " + scaleFactor,
            "settings put global window_animation_scale " + scaleFactor
        };
        // This must be 3 separate commands because StringTokenizer is used under-the-hood to
        // separate args (cannot do "sh -c 'quoted commands'").
        for (String command : commands) {
            executeShellCommand(command);
        }
    }

    @SuppressWarnings("SoonBlockedPrivateApi")
    private static void setViaReflection(float scaleFactor) {
        try {
            if (sWindowManagerObject == null) {
                Class<?> windowManagerStubClazz = Class.forName("android.view.IWindowManager$Stub");
                Method asInterface =
                        windowManagerStubClazz.getDeclaredMethod("asInterface", IBinder.class);
                Class<?> serviceManagerClazz = Class.forName("android.os.ServiceManager");
                Method getService =
                        serviceManagerClazz.getDeclaredMethod("getService", String.class);
                Class<?> windowManagerClazz = Class.forName("android.view.IWindowManager");
                sSetAnimationScalesMethod =
                        windowManagerClazz.getDeclaredMethod("setAnimationScales", float[].class);
                sGetAnimationScalesMethod =
                        windowManagerClazz.getDeclaredMethod("getAnimationScales");
                IBinder windowManagerBinder = (IBinder) getService.invoke(null, "window");
                sWindowManagerObject = asInterface.invoke(null, windowManagerBinder);
            }

            float[] scaleFactors = (float[]) sGetAnimationScalesMethod.invoke(sWindowManagerObject);
            Arrays.fill(scaleFactors, scaleFactor);
            sSetAnimationScalesMethod.invoke(sWindowManagerObject, scaleFactors);
        } catch (Exception e) {
            JavaUtils.throwUnchecked(e);
        }
    }

    @RequiresApi(VERSION_CODES.TIRAMISU)
    private static void setViaSupportedApi(float scaleFactor) {
        InstrumentationRegistry.getInstrumentation()
                .getUiAutomation()
                .setAnimationScale(scaleFactor);
    }
}