TileRenderer.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.wear.tiles.renderer;

import static androidx.core.util.Preconditions.checkNotNull;

import android.content.Context;
import android.util.ArrayMap;
import android.view.View;
import android.view.ViewGroup;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StyleRes;
import androidx.wear.protolayout.LayoutElementBuilders;
import androidx.wear.protolayout.ResourceBuilders;
import androidx.wear.protolayout.StateBuilders;
import androidx.wear.protolayout.expression.AppDataKey;
import androidx.wear.protolayout.expression.DynamicDataBuilders.DynamicDataValue;
import androidx.wear.protolayout.expression.PlatformDataKey;
import androidx.wear.protolayout.expression.pipeline.PlatformDataProvider;
import androidx.wear.protolayout.expression.pipeline.StateStore;
import androidx.wear.protolayout.proto.LayoutElementProto;
import androidx.wear.protolayout.proto.ResourceProto;
import androidx.wear.protolayout.renderer.impl.ProtoLayoutViewInstance;
import androidx.wear.protolayout.renderer.inflater.ProtoLayoutThemeImpl;
import androidx.wear.tiles.TileService;

import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;

import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;

/**
 * Renderer for Wear Tiles.
 *
 * <p>This variant uses Android views to represent the contents of the Wear Tile.
 */
public final class TileRenderer {
    /**
     * Listener for clicks on Clickable objects that have an Action to (re)load the contents of a
     * tile.
     *
     * @deprecated Use {@link Consumer<StateBuilders.State>} with {@link #TileRenderer(Context,
     *     Executor, Consumer)}.
     */
    @Deprecated
    public interface LoadActionListener {

        /**
         * Called when a Clickable that has a LoadAction is clicked.
         *
         * @param nextState The state that the next tile should be in.
         */
        void onClick(@NonNull androidx.wear.tiles.StateBuilders.State nextState);
    }

    @NonNull private final Context mUiContext;
    @NonNull private final Executor mLoadActionExecutor;
    @NonNull private final Consumer<StateBuilders.State> mLoadActionListener;

    @StyleRes int mTilesTheme = 0; // Default theme.

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

    @NonNull private final ProtoLayoutViewInstance mInstance;
    @Nullable private final LayoutElementProto.Layout mLayout;
    @Nullable private final ResourceProto.Resources mResources;
    @NonNull private final ListeningExecutorService mUiExecutor;
    @NonNull private final StateStore mStateStore = new StateStore(ImmutableMap.of());

    /**
     * Default constructor.
     *
     * @param uiContext A {@link Context} suitable for interacting with the UI.
     * @param layout The portion of the Tile to render.
     * @param resources The resources for the Tile.
     * @param loadActionExecutor Executor for {@code loadActionListener}.
     * @param loadActionListener Listener for clicks that will cause the contents to be reloaded.
     * @deprecated Use {@link TileRenderer.Builder} which accepts Layout and Resources in {@link
     *     #inflateAsync(LayoutElementBuilders.Layout, ResourceBuilders.Resources, ViewGroup)}
     *     method.
     */
    @Deprecated
    public TileRenderer(
            @NonNull Context uiContext,
            @NonNull androidx.wear.tiles.LayoutElementBuilders.Layout layout,
            @NonNull androidx.wear.tiles.ResourceBuilders.Resources resources,
            @NonNull Executor loadActionExecutor,
            @NonNull LoadActionListener loadActionListener) {
        this(
                uiContext,
                /* tilesTheme= */ 0,
                loadActionExecutor,
                toStateConsumer(loadActionListener),
                layout.toProto(),
                resources.toProto(),
                /* platformDataProviders */ null);
    }

    /**
     * Default constructor.
     *
     * @param uiContext A {@link Context} suitable for interacting with the UI.
     * @param layout The portion of the Tile to render.
     * @param tilesTheme The theme to use for this Tile instance. This can be used to customise
     *     things like the default font family. Pass 0 to use the default theme.
     * @param resources The resources for the Tile.
     * @param loadActionExecutor Executor for {@code loadActionListener}.
     * @param loadActionListener Listener for clicks that will cause the contents to be reloaded.
     * @deprecated Use {@link TileRenderer.Builder} which accepts Layout and Resources in {@link
     *     #inflateAsync(LayoutElementBuilders.Layout, ResourceBuilders.Resources, ViewGroup)}
     *     method.
     */
    @Deprecated
    public TileRenderer(
            @NonNull Context uiContext,
            @NonNull androidx.wear.tiles.LayoutElementBuilders.Layout layout,
            @StyleRes int tilesTheme,
            @NonNull androidx.wear.tiles.ResourceBuilders.Resources resources,
            @NonNull Executor loadActionExecutor,
            @NonNull LoadActionListener loadActionListener) {
        this(
                uiContext,
                tilesTheme,
                loadActionExecutor,
                toStateConsumer(loadActionListener),
                layout.toProto(),
                resources.toProto(),
                /* platformDataProviders */ null);
    }

    /**
     * Constructor for {@link TileRenderer}.
     *
     * <p>It is recommended to use the new {@link TileRenderer.Builder} instead.
     *
     * @param uiContext A {@link Context} suitable for interacting with the UI.
     * @param loadActionExecutor Executor for {@code loadActionListener}.
     * @param loadActionListener Listener for clicks that will cause the contents to be reloaded.
     */
    public TileRenderer(
            @NonNull Context uiContext,
            @NonNull Executor loadActionExecutor,
            @NonNull Consumer<StateBuilders.State> loadActionListener) {
        this(
                uiContext,
                /* tilesTheme= */ 0,
                loadActionExecutor,
                loadActionListener,
                /* layout= */ null,
                /* resources= */ null,
                /* platformDataProviders */ null);
    }

    private TileRenderer(
            @NonNull Context uiContext,
            @StyleRes int tilesTheme,
            @NonNull Executor loadActionExecutor,
            @NonNull Consumer<StateBuilders.State> loadActionListener,
            @Nullable LayoutElementProto.Layout layout,
            @Nullable ResourceProto.Resources resources,
            @Nullable Map<PlatformDataProvider, Set<PlatformDataKey<?>>> platformDataProviders) {

        this.mUiContext = uiContext;
        this.mTilesTheme = tilesTheme;
        this.mLoadActionExecutor = loadActionExecutor;
        this.mLoadActionListener = loadActionListener;
        this.mLayout = layout;
        this.mResources = resources;
        this.mUiExecutor = MoreExecutors.newDirectExecutorService();
        ProtoLayoutViewInstance.LoadActionListener instanceListener =
                nextState ->
                        loadActionExecutor.execute(
                                () ->
                                        loadActionListener.accept(
                                                StateBuilders.State.fromProto(nextState)));

        ProtoLayoutViewInstance.Config.Builder config =
                new ProtoLayoutViewInstance.Config.Builder(
                                uiContext, mUiExecutor, mUiExecutor, TileService.EXTRA_CLICKABLE_ID)
                        .setAnimationEnabled(true)
                        .setIsViewFullyVisible(true)
                        .setStateStore(mStateStore)
                        .setLoadActionListener(instanceListener);
        if (tilesTheme != 0) {
            config.setProtoLayoutTheme(new ProtoLayoutThemeImpl(uiContext, tilesTheme));
        }
        if (platformDataProviders != null) {
            for (Map.Entry<PlatformDataProvider, Set<PlatformDataKey<?>>> entry :
                    platformDataProviders.entrySet()) {
                config.addPlatformDataProvider(
                        entry.getKey(), entry.getValue().toArray(new PlatformDataKey[] {}));
            }
        }
        this.mInstance = new ProtoLayoutViewInstance(config.build());
    }

    @NonNull
    @SuppressWarnings("deprecation") // For backward compatibility
    private static Consumer<StateBuilders.State> toStateConsumer(
            @NonNull LoadActionListener loadActionListener) {
        return nextState ->
                loadActionListener.onClick(
                        androidx.wear.tiles.StateBuilders.State.fromProto(nextState.toProto()));
    }

    /**
     * Inflates a Tile into {@code parent}.
     *
     * @param parent The view to attach the tile into.
     * @return The first child that was inflated. This may be null if the Layout is empty or the
     *     top-level LayoutElement has no inner set, or the top-level LayoutElement contains an
     *     unsupported inner type.
     * @deprecated Use {@link #inflateAsync(LayoutElementBuilders.Layout,
     *     ResourceBuilders.Resources, ViewGroup)} instead. Note: This method only works with the
     *     deprecated constructors that accept Layout and Resources.
     */
    @Deprecated
    @Nullable
    public View inflate(@NonNull ViewGroup parent) {
        String errorMessage =
                "This method only works with the deprecated constructors that accept Layout and"
                        + " Resources.";
        try {
            // Waiting for the result from future for backwards compatibility.
            return inflateLayout(
                            checkNotNull(mLayout, errorMessage),
                            checkNotNull(mResources, errorMessage),
                            parent)
                    .get(10, TimeUnit.SECONDS);
        } catch (ExecutionException
                | InterruptedException
                | CancellationException
                | TimeoutException e) {
            // Wrap checked exceptions to avoid changing the method signature.
            throw new RuntimeException("Rendering tile has not successfully finished.", e);
        }
    }

    /**
     * Sets the state for the current (and future) layouts. This is equivalent to setting the tile
     * state via {@link StateBuilders.State.Builder#addKeyToValueMapping(AppDataKey,
     * DynamicDataValue)}
     *
     * @param newState the state to use for the current layout (and any future layouts). This value
     *     will replace any previously set state.
     * @throws IllegalStateException if number of {@code newState} entries is greater than {@link
     *     StateStore#getMaxStateEntryCount()}.
     */
    public void setState(@NonNull Map<AppDataKey<?>, DynamicDataValue<?>> newState) {
        mStateStore.setAppStateEntryValues(newState);
    }

    /**
     * Inflates a Tile into {@code parent}.
     *
     * @param layout The portion of the Tile to render.
     * @param resources The resources for the Tile.
     * @param parent The view to attach the tile into.
     * @return The future with the first child that was inflated. This may be null if the Layout is
     *     empty or the top-level LayoutElement has no inner set, or the top-level LayoutElement
     *     contains an unsupported inner type.
     */
    @NonNull
    public ListenableFuture<View> inflateAsync(
            @NonNull LayoutElementBuilders.Layout layout,
            @NonNull ResourceBuilders.Resources resources,
            @NonNull ViewGroup parent) {
        return inflateLayout(layout.toProto(), resources.toProto(), parent);
    }

    @NonNull
    private ListenableFuture<View> inflateLayout(
            @NonNull LayoutElementProto.Layout layout,
            @NonNull ResourceProto.Resources resources,
            @NonNull ViewGroup parent) {
        ListenableFuture<Void> result = mInstance.renderAndAttach(layout, resources, parent);
        return FluentFuture.from(result).transform(ignored -> parent.getChildAt(0), mUiExecutor);
    }

    /** Returns the {@link Context} suitable for interacting with the UI. */
    @NonNull
    public Context getUiContext() {
        return mUiContext;
    }

    /** Returns the {@link Executor} for {@code loadActionListener}. */
    @NonNull
    public Executor getLoadActionExecutor() {
        return mLoadActionExecutor;
    }

    /** Returns the Listener for clicks that will cause the contents to be reloaded. */
    @NonNull
    public Consumer<StateBuilders.State> getLoadActionListener() {
        return mLoadActionListener;
    }

    /**
     * Returns the theme to use for this Tile instance. This can be used to customise things like
     * the default font family. Defaults to zero (default theme) if not specified by {@link
     * Builder#setTilesTheme(int)}.
     */
    public int getTilesTheme() {
        return mTilesTheme;
    }

    /** Returns the platform data providers that will be registered for this Tile instance. */
    @NonNull
    public Map<PlatformDataProvider, Set<PlatformDataKey<?>>> getPlatformDataProviders() {
        return Collections.unmodifiableMap(mPlatformDataProviders);
    }

    /** Builder for {@link TileRenderer}. */
    public static final class Builder {
        @NonNull private final Context mUiContext;
        @NonNull private final Executor mLoadActionExecutor;
        @NonNull private final Consumer<StateBuilders.State> mLoadActionListener;

        @StyleRes int mTilesTheme = 0; // Default theme.

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

        /**
         * Builder for the {@link TileRenderer} class.
         *
         * @param uiContext A {@link Context} suitable for interacting with the UI.
         * @param loadActionExecutor Executor for {@code loadActionListener}.
         * @param loadActionListener Listener for clicks that will cause the contents to be
         *     reloaded.
         */
        public Builder(
                @NonNull Context uiContext,
                @NonNull Executor loadActionExecutor,
                @NonNull Consumer<StateBuilders.State> loadActionListener) {
            this.mUiContext = uiContext;
            this.mLoadActionExecutor = loadActionExecutor;
            this.mLoadActionListener = loadActionListener;
        }

        /**
         * Sets the theme to use for this Tile instance. This can be used to customise things like
         * the default font family. If not set, zero (default theme) will be used.
         */
        @NonNull
        public Builder setTilesTheme(@StyleRes int tilesTheme) {
            mTilesTheme = tilesTheme;
            return this;
        }

        /**
         * Adds a {@link PlatformDataProvider} that will be registered for the given {@code
         * supportedKeys}. Adding the same {@link PlatformDataProvider} several times will override
         * previous entries instead of adding multiple entries.
         */
        @NonNull
        public Builder addPlatformDataProvider(
                @NonNull PlatformDataProvider platformDataProvider,
                @NonNull PlatformDataKey<?>... supportedKeys) {
            this.mPlatformDataProviders.put(
                    platformDataProvider, ImmutableSet.copyOf(supportedKeys));
            return this;
        }

        /** Builds {@link TileRenderer} object. */
        @NonNull
        public TileRenderer build() {
            return new TileRenderer(
                    mUiContext,
                    mTilesTheme,
                    mLoadActionExecutor,
                    mLoadActionListener,
                    /* layout= */ null,
                    /* resources= */ null,
                    mPlatformDataProviders);
        }
    }
}