StateStore.java

/*
 * Copyright 2022 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.wear.protolayout.expression.pipeline;

import static java.util.stream.Collectors.toMap;

import android.annotation.SuppressLint;
import android.os.Handler;
import android.os.Looper;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.UiThread;
import androidx.collection.ArrayMap;
import androidx.collection.ArraySet;
import androidx.wear.protolayout.expression.AppDataKey;
import androidx.wear.protolayout.expression.DynamicDataBuilders;
import androidx.wear.protolayout.expression.DynamicDataKey;
import androidx.wear.protolayout.expression.proto.DynamicDataProto.DynamicDataValue;
import androidx.wear.protolayout.expression.PlatformDataKey;

import java.util.Collections;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.stream.Stream;

/**
 * State storage for ProtoLayout, which also supports sending callback when data items change.
 *
 * <p>Note that this class is **not** thread-safe. Since ProtoLayout inflation currently happens on
 * the main thread, and because updates will eventually affect the main thread, this whole class
 * must only be used from the UI thread.
 */
public class StateStore {
    @SuppressLint("MinMaxConstant")
    private static final int MAX_STATE_ENTRY_COUNT = 30;

    private final Executor mUiExecutor;
    @NonNull private final Map<AppDataKey<?>, DynamicDataValue> mCurrentAppState
            = new ArrayMap<>();

    @NonNull
    private final Map<PlatformDataKey<?>, DynamicDataValue> mCurrentPlatformData
            = new ArrayMap<>();

    @NonNull
    private final
    Map<DynamicDataKey<?>,
            Set<DynamicTypeValueReceiverWithPreUpdate<DynamicDataValue>>>
            mRegisteredCallbacks = new ArrayMap<>();

    @NonNull
    private final Map<PlatformDataKey<?>, PlatformDataProvider>
            mSourceKeyToDataProviders = new ArrayMap<>();

    @NonNull
    private final Map<PlatformDataProvider, Integer> mProviderToRegisteredKeyCount
            = new ArrayMap<>();

    /**
     * Creates a {@link StateStore}.
     *
     * @throws IllegalStateException if number of initialState entries is greater than
     * {@link StateStore#getMaxStateEntryCount()}.
     */
    @NonNull
    public static StateStore create(
            @NonNull Map<AppDataKey<?>, DynamicDataBuilders.DynamicDataValue>
                    initialState) {
        return new StateStore(toProto(initialState));
    }

    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    public StateStore(
            @NonNull Map<AppDataKey<?>, DynamicDataValue> initialState) {
        if (initialState.size() > getMaxStateEntryCount()) {
            throw stateTooLargeException(initialState.size());
        }
        mCurrentAppState.putAll(initialState);
        mUiExecutor = new MainThreadExecutor(new Handler(Looper.getMainLooper()));
    }

    void putAllPlatformProviders(
            @NonNull Map<PlatformDataKey<?>, PlatformDataProvider> sourceKeyToDataProviders) {
        mSourceKeyToDataProviders.putAll(sourceKeyToDataProviders);
    }

    /**
     * Sets the given app state, replacing the current app state.
     *
     * <p>Informs registered listeners of changed values, invalidates removed values.
     *
     * @throws IllegalStateException if number of state entries is greater than
     * {@link StateStore#getMaxStateEntryCount()}. The state will not update and old state entries
     * will stay in place.
     */
    @UiThread
    public void setAppStateEntryValues(
            @NonNull Map<AppDataKey<?>, DynamicDataBuilders.DynamicDataValue> newState) {
        setAppStateEntryValuesProto(toProto(newState));
    }

    /**
     * Sets the given app state, replacing the current app state.
     *
     * <p>Informs registered listeners of changed values, invalidates removed values.
     *
     * @throws IllegalStateException if number of state entries is larger than
     * {@link StateStore#getMaxStateEntryCount()}. The state will not update and old state entries
     * will stay in place.
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    @UiThread
    public void setAppStateEntryValuesProto(
            @NonNull Map<AppDataKey<?>, DynamicDataValue> newState) {
        if (newState.size() > getMaxStateEntryCount()) {
            throw stateTooLargeException(newState.size());
        }

        // Figure out which nodes have actually changed.
        Set<AppDataKey<?>> removedKeys = getRemovedAppKeys(newState);
        Map<AppDataKey<?>, DynamicDataValue> changedEntries = getChangedAppEntries(newState);

        Stream.concat(removedKeys.stream(), changedEntries.keySet().stream())
                .forEach(
                        key -> {
                            for (DynamicTypeValueReceiverWithPreUpdate<DynamicDataValue> callback :
                                    mRegisteredCallbacks.getOrDefault(
                                            key, Collections.emptySet())) {
                                callback.onPreUpdate();
                            }
                        });

        mCurrentAppState.clear();
        mCurrentAppState.putAll(newState);

        for (AppDataKey<?> key : removedKeys) {
            for (DynamicTypeValueReceiverWithPreUpdate<DynamicDataValue> callback :
                    mRegisteredCallbacks.getOrDefault(key, Collections.emptySet())) {
                callback.onInvalidated();
            }
        }
        for (Entry<AppDataKey<?>, DynamicDataValue> entry
                : changedEntries.entrySet()) {
            for (DynamicTypeValueReceiverWithPreUpdate<DynamicDataValue> callback :
                    mRegisteredCallbacks.getOrDefault(entry.getKey(), Collections.emptySet())) {
                callback.onData(entry.getValue());
            }
        }
    }

    /**
     * Update the given platform data item.
     *
     * <p>Informs registered listeners of changed values.
     */
    void updatePlatformDataEntries(
            @NonNull Map<PlatformDataKey<?>, DynamicDataBuilders.DynamicDataValue> newData) {
        updatePlatformDataEntryProto(
                newData.entrySet().stream().collect(
                        toMap(Entry::getKey, entry -> entry.getValue().toDynamicDataValueProto()))
        );
    }

    /**
     * Update the given platform data item.
     *
     * <p>Informs registered listeners of changed values.
     */
    void updatePlatformDataEntryProto(
            @NonNull Map<PlatformDataKey<?>, DynamicDataValue> newData) {
        Map<PlatformDataKey<?>, DynamicDataValue> changedEntries = new ArrayMap<>();
        for (Entry<PlatformDataKey<?>, DynamicDataValue> newEntry : newData.entrySet()) {
            DynamicDataValue currentEntry = mCurrentPlatformData.get(newEntry.getKey());
            if (currentEntry == null || !currentEntry.equals(newEntry.getValue())) {
                changedEntries.put(newEntry.getKey(), newEntry.getValue());
            }
        }

        for (Entry<PlatformDataKey<?>, DynamicDataValue> entry : changedEntries.entrySet()) {
            for (DynamicTypeValueReceiverWithPreUpdate<DynamicDataValue> callback :
                    mRegisteredCallbacks.getOrDefault(entry.getKey(), Collections.emptySet())) {
                callback.onPreUpdate();
            }
        }

        for (Entry<PlatformDataKey<?>, DynamicDataValue> entry : changedEntries.entrySet()) {
            for (DynamicTypeValueReceiverWithPreUpdate<DynamicDataValue> callback :
                    mRegisteredCallbacks.getOrDefault(entry.getKey(), Collections.emptySet())) {
                callback.onData(entry.getValue());
            }
            mCurrentPlatformData.put(entry.getKey(), entry.getValue());
        }
    }

    /**
     * Remove the platform data item with the given key.
     *
     * <p>Informs registered listeners by invalidating removed values.
     */
    void removePlatformDataEntry(@NonNull Set<PlatformDataKey<?>> keys) {
        for (PlatformDataKey<?> key : keys) {
            if (mCurrentPlatformData.get(key) != null) {
                for (DynamicTypeValueReceiverWithPreUpdate<DynamicDataValue> callback :
                        mRegisteredCallbacks.getOrDefault(key, Collections.emptySet())) {
                    callback.onPreUpdate();
                }
            }
        }

        for (PlatformDataKey<?> key : keys) {
            if (mCurrentPlatformData.get(key) != null) {
                for (DynamicTypeValueReceiverWithPreUpdate<DynamicDataValue> callback :
                        mRegisteredCallbacks.getOrDefault(key, Collections.emptySet())) {
                    callback.onInvalidated();
                }
                mCurrentPlatformData.remove(key);
            }
        }
    }

    /** Gets dynamic value with the given {@code key}. */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    @UiThread
    @Nullable
    public DynamicDataValue getDynamicDataValuesProto(@NonNull DynamicDataKey<?> key) {
        if (key instanceof AppDataKey) {
            return mCurrentAppState.get(key);
        }

        if (key instanceof PlatformDataKey) {
            return mCurrentPlatformData.get(key);
        }

        return null;
    }

    /**
     * Registers the given callback for updates to the data item for the given {@code key}.
     *
     * <p>Note that the callback will be executed on the UI thread.
     */
    @UiThread
    void registerCallback(
            @NonNull DynamicDataKey<?> key,
            @NonNull DynamicTypeValueReceiverWithPreUpdate<DynamicDataValue> callback) {
        mRegisteredCallbacks.computeIfAbsent(key, k -> new ArraySet<>()).add(callback);

        if (!(key instanceof PlatformDataKey) ||
                (mRegisteredCallbacks.containsKey(key) && mRegisteredCallbacks.get(key).size() > 1)
        ) {
            return;
        }

        PlatformDataProvider platformDataProvider = mSourceKeyToDataProviders.get(key);
        if (platformDataProvider != null) {
            int registeredKeyCount =
                    mProviderToRegisteredKeyCount.getOrDefault(platformDataProvider, 0);

            if (registeredKeyCount == 0) {
                platformDataProvider.registerForData(
                        mUiExecutor,
                        new PlatformDataReceiver() {
                            @Override
                            public void onData(
                                    @NonNull
                                    Map<PlatformDataKey<?>, DynamicDataBuilders.DynamicDataValue>
                                            newData) {
                                updatePlatformDataEntries(newData);
                            }

                            @Override
                            public void onInvalidated(@NonNull Set<PlatformDataKey<?>> keys) {
                                removePlatformDataEntry(keys);
                            }
                        });
            }

            mProviderToRegisteredKeyCount.put(platformDataProvider, registeredKeyCount + 1);
        } else {
            throw new IllegalArgumentException(
                    String.format("No platform data provider for %s", key));
        }
    }

    /** Unregisters the callback for the given {@code key} from receiving the updates. */
    @UiThread
    void unregisterCallback(
            @NonNull DynamicDataKey<?> key,
            @NonNull DynamicTypeValueReceiverWithPreUpdate<DynamicDataValue> callback) {

        Set<DynamicTypeValueReceiverWithPreUpdate<DynamicDataValue>> callbackSet =
                mRegisteredCallbacks.get(key);
        if (callbackSet != null) {
            callbackSet.remove(callback);

            if (!(key instanceof PlatformDataKey) || !callbackSet.isEmpty()) {
                return;
            }

            PlatformDataProvider platformDataProvider = mSourceKeyToDataProviders.get(key);
            if (platformDataProvider != null) {
                int registeredKeyCount =
                        mProviderToRegisteredKeyCount.getOrDefault(platformDataProvider, 0);
                if (registeredKeyCount == 1) {
                    platformDataProvider.unregisterForData();
                }
                mProviderToRegisteredKeyCount.put(platformDataProvider, registeredKeyCount - 1);
            } else {
                throw new IllegalArgumentException(
                        String.format("No platform data provider for %s", key));
            }
        }
    }

    /**
     * Returns the maximum number for state entries allowed for this {@link StateStore}.
     *
     * <p>The ProtoLayout state model is not designed to handle large volumes of layout provided
     * state. So we limit the number of state entries to keep the on-the-wire size and state
     * store update times manageable.
     */
    public static int getMaxStateEntryCount(){
        return MAX_STATE_ENTRY_COUNT;
    }

    @NonNull
    private static Map<AppDataKey<?>, DynamicDataValue> toProto(
            @NonNull Map<AppDataKey<?>, DynamicDataBuilders.DynamicDataValue> value) {
        return value.entrySet().stream()
                .collect(toMap(Entry::getKey, entry -> entry.getValue().toDynamicDataValueProto()));
    }

    @NonNull
    private Set<AppDataKey<?>> getRemovedAppKeys(
            @NonNull Map<AppDataKey<?>, DynamicDataValue> newState) {
        Set<AppDataKey<?>> result = new ArraySet<>(mCurrentAppState.keySet());
        result.removeAll(newState.keySet());
        return result;
    }

    @NonNull
    private Map<AppDataKey<?>, DynamicDataValue> getChangedAppEntries(
            @NonNull Map<AppDataKey<?>, DynamicDataValue> newState) {
        Map<AppDataKey<?>, DynamicDataValue> result = new ArrayMap<>();
        for (Entry<AppDataKey<?>, DynamicDataValue> newEntry
                : newState.entrySet()) {
            DynamicDataValue currentEntry = mCurrentAppState.get(newEntry.getKey());
            if (currentEntry == null || !currentEntry.equals(newEntry.getValue())) {
                result.put(newEntry.getKey(), newEntry.getValue());
            }
        }
        return result;
    }

    static IllegalStateException stateTooLargeException(int stateSize) {
        return new IllegalStateException(
                String.format(
                        "Too many state entries: %d. The maximum number of allowed state entries "
                                + "is %d.",
                        stateSize, getMaxStateEntryCount()));
    }
}