ExtensionWindowBackend.java

/*
 * Copyright 2020 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.window;

import static androidx.window.ExtensionHelper.DEBUG;
import static androidx.window.WindowManager.getActivityFromContext;

import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.os.IBinder;
import android.util.Log;

import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.core.util.Consumer;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executor;

/**
 * Default implementation of {@link WindowBackend} that uses a combination of platform APIs and
 * device-dependent OEM extensions.
 */
public final class ExtensionWindowBackend implements WindowBackend {
    private static volatile ExtensionWindowBackend sInstance;
    private static final Object sLock = new Object();

    @GuardedBy("sLock")
    private ExtensionInterfaceCompat mWindowExtension;
    /**
     * List of all registered callbacks for window layout info. Not protected by {@link #sLock} to
     * allow iterating and callback execution without holding the global lock.
     */
    private final List<WindowLayoutChangeCallbackWrapper> mWindowLayoutChangeCallbacks =
            new CopyOnWriteArrayList<>();
    /**
     * List of all registered callbacks for window layout info. Not protected by {@link #sLock} to
     * allow iterating and callback execution without holding the global lock.
     */
    private final List<DeviceStateChangeCallbackWrapper> mDeviceStateChangeCallbacks =
            new CopyOnWriteArrayList<>();
    /** Device state that was last reported through callbacks, used to filter out duplicates. */
    @GuardedBy("sLock")
    private DeviceState mLastReportedDeviceState;
    /** Window layouts that were last reported through callbacks, used to filter out duplicates. */
    @GuardedBy("sLock")
    private final HashMap<IBinder, WindowLayoutInfo> mLastReportedWindowLayouts =
            new HashMap<>();

    private static final String TAG = "WindowServer";

    private ExtensionWindowBackend() {
        // Empty
    }

    /**
     * Get the shared instance of the class.
     */
    @NonNull
    public static ExtensionWindowBackend getInstance(@NonNull Context context) {
        if (sInstance == null) {
            synchronized (sLock) {
                if (sInstance == null) {
                    sInstance = new ExtensionWindowBackend();
                    sInstance.initExtension(context.getApplicationContext());
                }
            }
        }
        return sInstance;
    }

    /** Try to initialize Extension, returns early if it's not available. */
    @SuppressLint("SyntheticAccessor")
    @GuardedBy("sLock")
    private void initExtension(Context context) {
        mWindowExtension = ExtensionHelper.getExtensionImpl(context);
        if (mWindowExtension == null) {
            return;
        }
        mWindowExtension.setExtensionCallback(new ExtensionListenerImpl());
    }

    @NonNull
    @Override
    public WindowLayoutInfo getWindowLayoutInfo(@NonNull Context context) {
        Activity activity = assertActivityContext(context);
        IBinder windowToken = getActivityWindowToken(activity);
        if (windowToken == null) {
            throw new IllegalStateException("Activity does not have a window attached.");
        }

        synchronized (sLock) {
            WindowLayoutInfo extensionWindowLayoutInfo = mWindowExtension != null
                    ? mWindowExtension.getWindowLayoutInfo(windowToken)
                    : new WindowLayoutInfo(new ArrayList<>());
            mLastReportedWindowLayouts.put(windowToken, extensionWindowLayoutInfo);
            return extensionWindowLayoutInfo;
        }
    }

    @NonNull
    @Override
    public DeviceState getDeviceState() {
        synchronized (sLock) {
            return mWindowExtension != null ? mWindowExtension.getDeviceState() :
                    new DeviceState(DeviceState.POSTURE_UNKNOWN);
        }
    }

    @Override
    public void registerLayoutChangeCallback(@NonNull Context context,
            @NonNull Executor executor, @NonNull Consumer<WindowLayoutInfo> callback) {
        synchronized (sLock) {
            if (mWindowExtension == null) {
                if (DEBUG) {
                    Log.v(TAG, "Extension not loaded, skipping callback registration.");
                }
                return;
            }

            Activity activity = assertActivityContext(context);
            IBinder windowToken = getActivityWindowToken(activity);
            if (windowToken == null) {
                throw new IllegalStateException("Activity does not have a window attached.");
            }

            // Check if the token was already registered, in case we need to report tracking of a
            // new token to the extension.
            boolean registeredToken = false;
            for (WindowLayoutChangeCallbackWrapper callbackWrapper : mWindowLayoutChangeCallbacks) {
                if (callbackWrapper.mToken.equals(windowToken)) {
                    registeredToken = true;
                    break;
                }
            }

            final WindowLayoutChangeCallbackWrapper callbackWrapper =
                    new WindowLayoutChangeCallbackWrapper(windowToken, executor, callback);
            mWindowLayoutChangeCallbacks.add(callbackWrapper);
            if (!registeredToken) {
                // Added the first callback for the token.
                mWindowExtension.onWindowLayoutChangeListenerAdded(windowToken);
            }
        }
    }

    @Override
    public void unregisterLayoutChangeCallback(@NonNull Consumer<WindowLayoutInfo> callback) {
        synchronized (sLock) {
            if (mWindowExtension == null) {
                if (DEBUG) {
                    Log.v(TAG, "Extension not loaded, skipping callback un-registration.");
                }
                return;
            }

            // The same callback may be registered for multiple different window tokens, and
            // vice-versa. First collect all items to be removed.
            List<WindowLayoutChangeCallbackWrapper> itemsToRemove = new ArrayList<>();
            for (WindowLayoutChangeCallbackWrapper callbackWrapper : mWindowLayoutChangeCallbacks) {
                Consumer<WindowLayoutInfo> registeredCallback = callbackWrapper.mCallback;
                if (registeredCallback == callback) {
                    itemsToRemove.add(callbackWrapper);
                }
            }
            // Remove the items from the list and notify extension if needed.
            mWindowLayoutChangeCallbacks.removeAll(itemsToRemove);
            for (WindowLayoutChangeCallbackWrapper callbackWrapper : itemsToRemove) {
                callbackRemovedForToken(callbackWrapper.mToken);
            }
        }
    }

    /**
     * Check if there are no more registered callbacks left for the token and inform extension if
     * needed.
     */
    @GuardedBy("sLock")
    private void callbackRemovedForToken(IBinder token) {
        for (WindowLayoutChangeCallbackWrapper callbackWrapper : mWindowLayoutChangeCallbacks) {
            if (callbackWrapper.mToken.equals(token)) {
                // Found a registered callback for token.
                return;
            }
        }
        // No registered callbacks left for token - report to extension.
        mWindowExtension.onWindowLayoutChangeListenerRemoved(token);
    }

    @Override
    public void registerDeviceStateChangeCallback(@NonNull Executor executor,
            @NonNull Consumer<DeviceState> callback) {
        synchronized (sLock) {
            if (mWindowExtension == null) {
                if (DEBUG) {
                    Log.d(TAG, "Extension not loaded, skipping callback registration.");
                }
                return;
            }

            if (mDeviceStateChangeCallbacks.isEmpty()) {
                mWindowExtension.onDeviceStateListenersChanged(false /* isEmpty */);
            }

            final DeviceStateChangeCallbackWrapper callbackWrapper =
                    new DeviceStateChangeCallbackWrapper(executor, callback);
            mDeviceStateChangeCallbacks.add(callbackWrapper);
        }
    }

    @Override
    public void unregisterDeviceStateChangeCallback(@NonNull Consumer<DeviceState> callback) {
        synchronized (sLock) {
            if (mWindowExtension == null) {
                if (DEBUG) {
                    Log.d(TAG, "Extension not loaded, skipping callback un-registration.");
                }
                return;
            }

            for (DeviceStateChangeCallbackWrapper callbackWrapper : mDeviceStateChangeCallbacks) {
                if (callbackWrapper.mCallback.equals(callback)) {
                    mDeviceStateChangeCallbacks.remove(callbackWrapper);
                    if (mDeviceStateChangeCallbacks.isEmpty()) {
                        mWindowExtension.onDeviceStateListenersChanged(true /* isEmpty */);
                    }
                    return;
                }
            }
        }
    }

    private class ExtensionListenerImpl implements
            ExtensionInterfaceCompat.ExtensionCallbackInterface {
        @Override
        @SuppressLint("SyntheticAccessor")
        public void onDeviceStateChanged(@NonNull DeviceState newDeviceState) {
            synchronized (sLock) {
                if (newDeviceState.equals(mLastReportedDeviceState)) {
                    // Skipping, value already reported
                    if (DEBUG) {
                        Log.w(TAG, "Extension reported old layout value");
                    }
                    return;
                }
                mLastReportedDeviceState = newDeviceState;
            }

            for (DeviceStateChangeCallbackWrapper callbackWrapper : mDeviceStateChangeCallbacks) {
                callbackWrapper.mExecutor.execute(new Runnable() {
                    @Override
                    public void run() {
                        callbackWrapper.mCallback.accept(newDeviceState);
                    }
                });
            }
        }

        @Override
        @SuppressLint("SyntheticAccessor")
        public void onWindowLayoutChanged(@NonNull IBinder windowToken,
                @NonNull WindowLayoutInfo newLayout) {
            synchronized (sLock) {
                WindowLayoutInfo lastReportedValue = mLastReportedWindowLayouts.get(windowToken);
                if (newLayout.equals(lastReportedValue)) {
                    // Skipping, value already reported
                    if (DEBUG) {
                        Log.w(TAG, "Extension reported an old layout value");
                    }
                    return;
                }
                mLastReportedWindowLayouts.put(windowToken, newLayout);
            }

            for (WindowLayoutChangeCallbackWrapper callbackWrapper : mWindowLayoutChangeCallbacks) {
                if (!callbackWrapper.mToken.equals(windowToken)) {
                    continue;
                }

                callbackWrapper.mExecutor.execute(new Runnable() {
                    @Override
                    public void run() {
                        callbackWrapper.mCallback.accept(newLayout);
                    }
                });
            }
        }
    }

    private Activity assertActivityContext(Context context) {
        Activity activity = getActivityFromContext(context);
        if (activity == null) {
            throw new IllegalArgumentException("Used non-visual Context with WindowManager. "
                    + "Please use an Activity or a ContextWrapper around an Activity instead.");
        }
        return activity;
    }

    private IBinder getActivityWindowToken(Activity activity) {
        return activity.getWindow().getAttributes().token;
    }

    /**
     * Wrapper around {@link Consumer<WindowLayoutInfo>} that also includes the {@link Executor}
     * on which the callback should run and the associated token.
     */
    private static class WindowLayoutChangeCallbackWrapper {
        final Executor mExecutor;
        final Consumer<WindowLayoutInfo> mCallback;
        final IBinder mToken;

        WindowLayoutChangeCallbackWrapper(@NonNull IBinder token, @NonNull Executor executor,
                @NonNull Consumer<WindowLayoutInfo> callback) {
            mToken = token;
            mExecutor = executor;
            mCallback = callback;
        }
    }

    /**
     * Wrapper around {@link Consumer<DeviceState>} that also includes the {@link Executor} on
     * which the callback should run.
     */
    private static class DeviceStateChangeCallbackWrapper {
        final Executor mExecutor;
        final Consumer<DeviceState> mCallback;

        DeviceStateChangeCallbackWrapper(@NonNull Executor executor,
                @NonNull Consumer<DeviceState> callback) {
            mExecutor = executor;
            mCallback = callback;
        }
    }
}