RotationProvider.java

/*
 * Copyright 2021 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.camera.view;

import android.app.Activity;
import android.content.Context;
import android.hardware.SensorManager;
import android.view.OrientationEventListener;
import android.view.Surface;

import androidx.annotation.CheckResult;
import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import androidx.camera.core.UseCase;
import androidx.camera.core.impl.ImageOutputConfig;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * Provider for receiving rotation updates from the {@link SensorManager} when the rotation of
 * the device has changed.
 *
 * <p> This class monitors motion sensor and notifies the listener about physical orientation
 * changes in the format of {@link Surface} rotation. It's useful when the {@link Activity} is in
 * a fixed portrait or landscape orientation, while the app still wants to set the
 * {@link UseCase} target rotation based on the device's physical rotation.
 *
 * <pre><code>
 * // Create a provider.
 * RotationProvider mRotationProvider = new RotationProvider(getContext());
 *
 * // Add listener to receive updates.
 * mRotationProvider.addListener(rotation -> {
 *     mImageCapture.setTargetRotation(rotation);
 * });
 *
 * // Remove when no longer needed.
 * mRotationProvider.clearListener();
 * </code></pre>
 */
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
public final class RotationProvider {

    final Object mLock = new Object();

    @GuardedBy("mLock")
    @VisibleForTesting
    @NonNull
    final OrientationEventListener mOrientationListener;

    // Synthetic access
    @SuppressWarnings("WeakerAccess")
    @GuardedBy("mLock")
    @NonNull
    final Map<Listener, ListenerWrapper> mListeners = new HashMap<>();

    // Set this value to true to test adding listener in unit tests.
    @VisibleForTesting
    boolean mIgnoreCanDetectForTest = false;

    /**
     * Creates a new RotationProvider.
     */
    public RotationProvider(@NonNull Context context) {
        mOrientationListener = new OrientationEventListener(context) {
            private static final int INVALID_SURFACE_ROTATION = -1;

            private int mRotation = INVALID_SURFACE_ROTATION;

            @Override
            public void onOrientationChanged(int orientation) {
                if (orientation == OrientationEventListener.ORIENTATION_UNKNOWN) {
                    // Short-circuit if orientation is unknown. Unknown rotation
                    // can't be handled so it shouldn't be sent.
                    return;
                }

                int newRotation = orientationToSurfaceRotation(orientation);
                if (mRotation != newRotation) {
                    mRotation = newRotation;
                    List<ListenerWrapper> listeners;
                    // Take a snapshot for thread safety.
                    synchronized (mLock) {
                        listeners = new ArrayList<>(mListeners.values());
                    }
                    if (!listeners.isEmpty()) {
                        for (ListenerWrapper listenerWrapper : listeners) {
                            listenerWrapper.onRotationChanged(newRotation);
                        }
                    }
                }
            }
        };
    }

    /**
     * Sets a {@link Listener} that listens for rotation changes.
     *
     * @param executor The executor in which the {@link {@link Listener#onRotationChanged(int)}
     *                 will be run.
     * @return false if the device cannot detection rotation changes. In that case, the listener
     * will not be set.
     */
    @CheckResult
    public boolean addListener(@NonNull Executor executor, @NonNull Listener listener) {
        synchronized (mLock) {
            if (!mOrientationListener.canDetectOrientation() && !mIgnoreCanDetectForTest) {
                return false;
            }
            mListeners.put(listener, new ListenerWrapper(listener, executor));
            mOrientationListener.enable();
        }
        return true;
    }

    /**
     * Removes the given {@link Listener} from this object.
     *
     * <p> The removed listener will no longer receive rotation updates.
     */
    public void removeListener(@NonNull Listener listener) {
        synchronized (mLock) {
            ListenerWrapper listenerWrapper = mListeners.get(listener);
            if (listenerWrapper != null) {
                listenerWrapper.disable();
                mListeners.remove(listener);
            }
            if (mListeners.isEmpty()) {
                mOrientationListener.disable();
            }
        }
    }

    /**
     * Converts orientation degrees to {@link Surface} rotation.
     */
    @VisibleForTesting
    static int orientationToSurfaceRotation(@ImageOutputConfig.RotationValue int orientation) {
        if (orientation >= 315 || orientation < 45) {
            return Surface.ROTATION_0;
        } else if (orientation >= 225) {
            return Surface.ROTATION_90;
        } else if (orientation >= 135) {
            return Surface.ROTATION_180;
        } else {
            return Surface.ROTATION_270;
        }
    }

    /**
     * Wrapper of {@link Listener} with the executor and a tombstone flag.
     */
    private static class ListenerWrapper {
        private final Listener mListener;
        private final Executor mExecutor;
        private final AtomicBoolean mEnabled;

        ListenerWrapper(Listener listener, Executor executor) {
            mListener = listener;
            mExecutor = executor;
            mEnabled = new AtomicBoolean(true);
        }

        void onRotationChanged(@ImageOutputConfig.RotationValue int rotation) {
            mExecutor.execute(() -> {
                if (mEnabled.get()) {
                    mListener.onRotationChanged(rotation);
                }
            });
        }

        /**
         * Once disabled, the app will not receive callback even if it has already been posted on
         * the callback thread.
         */
        void disable() {
            mEnabled.set(false);
        }
    }

    /**
     * Callback interface to receive rotation updates.
     */
    public interface Listener {

        /**
         * Called when the physical rotation of the device changes.
         *
         * <p> The rotation is one of the {@link Surface} rotations mapped from orientation
         * degrees.
         *
         * <table summary="Orientation degrees to Surface rotation mapping">
         * <tr><th>Orientation degrees</th><th>Surface rotation</th></tr>
         * <tr><td>[-45°, 45°)</td><td>{@link Surface#ROTATION_0}</td></tr>
         * <tr><td>[45°, 135°)</td><td>{@link Surface#ROTATION_270}</td></tr>
         * <tr><td>[135°, 225°)</td><td>{@link Surface#ROTATION_180}</td></tr>
         * <tr><td>[225°, 315°)</td><td>{@link Surface#ROTATION_90}</td></tr>
         * </table>
         */
        void onRotationChanged(@ImageOutputConfig.RotationValue int rotation);
    }
}