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.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.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.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.Map;
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 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(Context, Executor, Consumer)} 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());
    }

    /**
     * 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(Context, Executor, Consumer)} 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());
    }

    /**
     * @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);
    }

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

        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));
        }
        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);
    }
}