ResourceResolvers.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.protolayout.renderer.inflater;

import android.annotation.SuppressLint;
import android.graphics.drawable.Drawable;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicFloat;
import androidx.wear.protolayout.proto.ResourceProto;
import androidx.wear.protolayout.proto.ResourceProto.AndroidAnimatedImageResourceByResId;
import androidx.wear.protolayout.proto.ResourceProto.AndroidImageResourceByContentUri;
import androidx.wear.protolayout.proto.ResourceProto.AndroidImageResourceByResId;
import androidx.wear.protolayout.proto.ResourceProto.AndroidSeekableAnimatedImageResourceByResId;
import androidx.wear.protolayout.proto.ResourceProto.InlineImageResource;
import androidx.wear.protolayout.proto.TriggerProto.Trigger;

import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;

/**
 * Class for resolving resources. Delegates the actual work to different types of resolver classes,
 * and allows each type of resolver to be configured individually, as well as instantiation from
 * common resolver implementations.
 */
public class ResourceResolvers {
    private final ResourceProto.Resources mProtoResources;

    @Nullable
    private final AndroidImageResourceByResIdResolver mAndroidImageResourceByResIdResolver;

    @Nullable
    private final AndroidAnimatedImageResourceByResIdResolver
            mAndroidAnimatedImageResourceByResIdResolver;

    @Nullable
    private final AndroidSeekableAnimatedImageResourceByResIdResolver
            mAndroidSeekableAnimatedImageResourceByResIdResolver;

    @Nullable private final InlineImageResourceResolver mInlineImageResourceResolver;

    @Nullable
    private final AndroidImageResourceByContentUriResolver
            mAndroidImageResourceByContentUriResolver;

    ResourceResolvers(
            @NonNull ResourceProto.Resources protoResources,
            @Nullable AndroidImageResourceByResIdResolver androidImageResourceByResIdResolver,
            @Nullable
                    AndroidAnimatedImageResourceByResIdResolver
                            androidAnimatedImageResourceByResIdResolver,
            @Nullable
                    AndroidSeekableAnimatedImageResourceByResIdResolver
                            androidSeekableAnimatedImageResourceByResIdResolver,
            @Nullable InlineImageResourceResolver inlineImageResourceResolver,
            @Nullable AndroidImageResourceByContentUriResolver androidContentUriResolver) {
        this.mProtoResources = protoResources;
        this.mAndroidImageResourceByResIdResolver = androidImageResourceByResIdResolver;
        this.mAndroidAnimatedImageResourceByResIdResolver =
                androidAnimatedImageResourceByResIdResolver;
        this.mAndroidSeekableAnimatedImageResourceByResIdResolver =
                androidSeekableAnimatedImageResourceByResIdResolver;
        this.mInlineImageResourceResolver = inlineImageResourceResolver;
        this.mAndroidImageResourceByContentUriResolver = androidContentUriResolver;
    }

    /** Exception thrown when accessing resources. */
    public static final class ResourceAccessException extends Exception {
        public ResourceAccessException(@NonNull String description) {
            super(description);
        }

        public ResourceAccessException(@NonNull String description, @NonNull Exception cause) {
            super(description, cause);
        }
    }

    /** Interface that can provide a Drawable for an AndroidImageResourceByResId */
    public interface AndroidImageResourceByResIdResolver {
        /**
         * Should immediately return the drawable specified by {@code resource}.
         *
         * @throws ResourceAccessException If the drawable cannot be found
         */
        @NonNull
        Drawable getDrawableOrThrow(@NonNull AndroidImageResourceByResId resource)
                throws ResourceAccessException;
    }

    /** Interface that can provide a Drawable for an AndroidAnimatedImageResourceByResId */
    public interface AndroidAnimatedImageResourceByResIdResolver {
        /**
         * Should immediately return the drawable specified by {@code resource}.
         *
         * @throws ResourceAccessException If the drawable cannot be found.
         */
        @NonNull
        Drawable getDrawableOrThrow(@NonNull AndroidAnimatedImageResourceByResId resource)
                throws ResourceAccessException;
    }

    /** Interface that can provide a Drawable for an AndroidSeekableAnimatedImageResourceByResId */
    public interface AndroidSeekableAnimatedImageResourceByResIdResolver {
        /**
         * Should immediately return the drawable specified by {@code resource}.
         *
         * @throws ResourceAccessException If the drawable cannot be found.
         */
        @NonNull
        Drawable getDrawableOrThrow(@NonNull AndroidSeekableAnimatedImageResourceByResId resource)
                throws ResourceAccessException;
    }

    /** Interface that can provide a Drawable for an InlineImageResource */
    public interface InlineImageResourceResolver {
        /**
         * Should immediately return the drawable specified by {@code resource}.
         *
         * @throws ResourceAccessException If the drawable cannot be found,.
         */
        @NonNull
        Drawable getDrawableOrThrow(@NonNull InlineImageResource resource)
                throws ResourceAccessException;
    }

    /** Interface that can provide a Drawable for an AndroidContentUriResource. */
    public interface AndroidImageResourceByContentUriResolver {
        /** Get the drawable as specified by {@code resource}, to be loaded asynchronously. */
        @NonNull
        ListenableFuture<Drawable> getDrawable(@NonNull AndroidImageResourceByContentUri resource);
    }

    /** Get an empty builder to build {@link ResourceResolvers} with. */
    @NonNull
    public static Builder builder(@NonNull ResourceProto.Resources protoResources) {
        return new Builder(protoResources);
    }

    /**
     * Returns whether the resource specified by {@code protoResourceId} has a placeholder resource
     * associated with it.
     */
    public boolean hasPlaceholderDrawable(@NonNull String protoResourceId) {
        return getPlaceholderResourceId(protoResourceId) != null;
    }

    /**
     * Returns the placeholder drawable for the resource specified by {@code protoResourceId}.
     *
     * @throws ResourceAccessException If the specified resource does not have a placeholder
     *     associated, or the placeholder could not be loaded.
     * @throws IllegalArgumentException If the specified resource, or its placeholder, does not
     *     exist.
     * @see ResourceResolvers#hasPlaceholderDrawable(String)
     */
    @NonNull
    public Drawable getPlaceholderDrawableOrThrow(@NonNull String protoResourceId)
            throws ResourceAccessException {
        String placeholderResourceId = getPlaceholderResourceId(protoResourceId);

        if (placeholderResourceId == null) {
            throw new ResourceAccessException(
                    "Resource " + protoResourceId + " does not have a placeholder resource.");
        }

        ResourceProto.ImageResource placeholderImageResource =
                mProtoResources.getIdToImageMap().get(placeholderResourceId);

        if (placeholderImageResource == null) {
            throw new IllegalArgumentException(
                    "Resource " + placeholderResourceId + " is not defined in resources bundle");
        }

        Drawable placeHolderDrawable =
                getDrawableForImageResourceSynchronously(placeholderImageResource);
        if (placeHolderDrawable != null) {
            return placeHolderDrawable;
        }

        if (placeholderImageResource.hasAndroidContentUri()) {
            throw new ResourceAccessException("Content URI images cannot be used as placeholders");
        }

        throw new ResourceAccessException("Can't find resolver for image resource.");
    }

    /** Get the drawable corresponding to the given resource ID. */
    @NonNull
    public ListenableFuture<Drawable> getDrawable(@NonNull String protoResourceId) {
        ResourceProto.ImageResource imageResource =
                mProtoResources.getIdToImageMap().get(protoResourceId);

        if (imageResource == null) {
            return Futures.immediateFailedFuture(new IllegalArgumentException(
                                "Resource " + protoResourceId + " is not defined in resources bundle"));
        }

        @Nullable
        ListenableFuture<Drawable> drawableFutureOrNull =
                getDrawableForImageResource(imageResource);
        if (drawableFutureOrNull == null) {
            return Futures.immediateFailedFuture(new ResourceAccessException(
                                "Can't find resolver for image resource " + protoResourceId));
        }
        return drawableFutureOrNull;
    }

    /**
     * Get the animation trigger for the given animated image resource id
     *
     * @throws IllegalArgumentException If the resource is not an animated resource.
     */
    @Nullable
    public Trigger getAnimationTrigger(@NonNull String protoResourceId) {
        ResourceProto.ImageResource imageResource =
                mProtoResources.getIdToImageMap().get(protoResourceId);
        if (imageResource != null && imageResource.hasAndroidAnimatedResourceByResId()) {
            return imageResource.getAndroidAnimatedResourceByResId().getStartTrigger();
        }
        throw new IllegalArgumentException(
                "Resource "
                        + protoResourceId
                        + " is not an animated resource, thus no animation trigger");
    }

    /**
     * Get the animation bound progress for the given animated image resource id
     *
     * @throws IllegalArgumentException If the resource is not a seekable animated resource.
     */
    @Nullable
    public DynamicFloat getBoundProgress(@NonNull String protoResourceId) {
        ResourceProto.ImageResource imageResource =
                mProtoResources.getIdToImageMap().get(protoResourceId);
        if (imageResource != null && imageResource.hasAndroidSeekableAnimatedResourceByResId()) {
            return imageResource.getAndroidSeekableAnimatedResourceByResId().getProgress();
        }
        throw new IllegalArgumentException(
                "Resource "
                        + protoResourceId
                        + " is not a seekable animated resource, thus no bound progress to a"
                        + " DynamicFloat");
    }

    @Nullable
    Drawable getDrawableForImageResourceSynchronously(
            @NonNull ResourceProto.ImageResource imageResource) throws ResourceAccessException {
        if (imageResource.hasAndroidAnimatedResourceByResId()
                && mAndroidAnimatedImageResourceByResIdResolver != null) {
            AndroidAnimatedImageResourceByResIdResolver resolver =
                    mAndroidAnimatedImageResourceByResIdResolver;
            return resolver.getDrawableOrThrow(imageResource.getAndroidAnimatedResourceByResId());
        }

        if (imageResource.hasAndroidSeekableAnimatedResourceByResId()
                && mAndroidSeekableAnimatedImageResourceByResIdResolver != null) {
            AndroidSeekableAnimatedImageResourceByResIdResolver resolver =
                    mAndroidSeekableAnimatedImageResourceByResIdResolver;
            return resolver.getDrawableOrThrow(
                    imageResource.getAndroidSeekableAnimatedResourceByResId());
        }

        if (imageResource.hasAndroidResourceByResId()
                && mAndroidImageResourceByResIdResolver != null) {
            AndroidImageResourceByResIdResolver resolver = mAndroidImageResourceByResIdResolver;
            return resolver.getDrawableOrThrow(imageResource.getAndroidResourceByResId());
        }

        if (imageResource.hasInlineResource() && mInlineImageResourceResolver != null) {
            InlineImageResourceResolver resolver = mInlineImageResourceResolver;
            return resolver.getDrawableOrThrow(imageResource.getInlineResource());
        }

        return null;
    }

    /**
     * Get the drawable for the known ImageResource. Can return null if there's no resolver for the
     * image resource.
     */
    @Nullable
    protected ListenableFuture<Drawable> getDrawableForImageResource(
            @NonNull ResourceProto.ImageResource imageResource) {
        try {
            Drawable drawable = getDrawableForImageResourceSynchronously(imageResource);
            if (drawable != null) {
                return Futures.immediateFuture(drawable);
            }
        } catch (ResourceAccessException e) {
            return Futures.immediateFailedFuture(e);
        }

        if (imageResource.hasAndroidContentUri()
                && mAndroidImageResourceByContentUriResolver != null) {
            AndroidImageResourceByContentUriResolver resolver =
                    mAndroidImageResourceByContentUriResolver;
            return resolver.getDrawable(imageResource.getAndroidContentUri());
        }

        // Can't find resolver for image resource.
        return null;
    }

    public boolean canImageBeTinted(@NonNull String protoResourceId) {
        // Only Android image resources can be tinted for now. This is because we don't really know
        // what is in an inline image.
        ResourceProto.ImageResource imageResource =
                mProtoResources.getIdToImageMap().get(protoResourceId);

        if (imageResource == null) {
            throw new IllegalArgumentException(
                    "Resource " + protoResourceId + " is not defined in resources bundle");
        }

        if (imageResource.hasAndroidResourceByResId()
                || imageResource.hasAndroidAnimatedResourceByResId()
                || imageResource.hasAndroidSeekableAnimatedResourceByResId()) {
            return true;
        }

        return false;
    }

    @Nullable
    protected String getPlaceholderResourceId(@NonNull String originalResourceId) {
        ResourceProto.ImageResource imageResource =
                mProtoResources.getIdToImageMap().get(originalResourceId);

        if (imageResource == null) {
            throw new IllegalArgumentException(
                    "Resource " + originalResourceId + " is not defined in resources bundle");
        }

        return null;
    }

    /** Builder for ResourceResolvers */
    public static final class Builder {
        @NonNull private final ResourceProto.Resources mProtoResources;
        @Nullable private AndroidImageResourceByResIdResolver mAndroidImageResourceByResIdResolver;

        @Nullable
        private AndroidAnimatedImageResourceByResIdResolver
                mAndroidAnimatedImageResourceByResIdResolver;

        @Nullable
        private AndroidSeekableAnimatedImageResourceByResIdResolver
                mAndroidSeekableAnimatedImageResourceByResIdResolver;

        @Nullable private InlineImageResourceResolver mInlineImageResourceResolver;

        @Nullable
        private AndroidImageResourceByContentUriResolver mAndroidImageResourceByContentUriResolver;

        Builder(@NonNull ResourceProto.Resources protoResources) {
            this.mProtoResources = protoResources;
        }

        /** Set the resource loader for {@link AndroidImageResourceByResIdResolver} resources. */
        @NonNull
        @SuppressLint("MissingGetterMatchingBuilder")
        public Builder setAndroidImageResourceByResIdResolver(
                @NonNull AndroidImageResourceByResIdResolver resolver) {
            mAndroidImageResourceByResIdResolver = resolver;
            return this;
        }

        /**
         * Set the resource loader for {@link AndroidAnimatedImageResourceByResIdResolver}
         * resources.
         */
        @NonNull
        @SuppressLint("MissingGetterMatchingBuilder")
        public Builder setAndroidAnimatedImageResourceByResIdResolver(
                @NonNull AndroidAnimatedImageResourceByResIdResolver resolver) {
            mAndroidAnimatedImageResourceByResIdResolver = resolver;
            return this;
        }

        /**
         * Set the resource loader for {@link AndroidSeekableAnimatedImageResourceByResIdResolver}
         * resources.
         */
        @NonNull
        @SuppressLint("MissingGetterMatchingBuilder")
        public Builder setAndroidSeekableAnimatedImageResourceByResIdResolver(
                @NonNull AndroidSeekableAnimatedImageResourceByResIdResolver resolver) {
            mAndroidSeekableAnimatedImageResourceByResIdResolver = resolver;
            return this;
        }

        /** Set the resource loader for {@link InlineImageResourceResolver} resources. */
        @NonNull
        @SuppressLint("MissingGetterMatchingBuilder")
        public Builder setInlineImageResourceResolver(
                @NonNull InlineImageResourceResolver resolver) {
            mInlineImageResourceResolver = resolver;
            return this;
        }

        @NonNull
        @SuppressLint("MissingGetterMatchingBuilder")
        public Builder setAndroidImageResourceByContentUriResolver(
                @NonNull AndroidImageResourceByContentUriResolver resolver) {
            mAndroidImageResourceByContentUriResolver = resolver;
            return this;
        }

        /** Build a {@link ResourceResolvers} instance. */
        @NonNull
        public ResourceResolvers build() {
            return new ResourceResolvers(
                    mProtoResources,
                    mAndroidImageResourceByResIdResolver,
                    mAndroidAnimatedImageResourceByResIdResolver,
                    mAndroidSeekableAnimatedImageResourceByResIdResolver,
                    mInlineImageResourceResolver,
                    mAndroidImageResourceByContentUriResolver);
        }
    }
}