OngoingActivity.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.wear.ongoing;

import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.graphics.drawable.Icon;
import android.os.Build;
import android.os.SystemClock;
import android.service.notification.StatusBarNotification;

import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationCompat;
import androidx.core.content.LocusIdCompat;
import androidx.core.util.Preconditions;

import java.util.function.Predicate;

/**
 * Main class to access the Ongoing Activities API.
 *
 * It's created with the {@link Builder}. After it's created (and before building and
 * posting the {@link Notification}) {@link OngoingActivity#apply(Context)} apply needs to be
 * called:
 *
 * <pre>{@code
 * NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
 * ....
 * OngoingActivity ongoingActivity = new OngoingActivity.Builder(context, notificationId, builder);
 * ....
 * ongoingActivity.apply(context);
 * notificationManager.notify(notificationId, builder.build());
 * }</pre>
 *
 * Note that the notification passed to the {@link Builder} is also usen to take defaults if they
 * are not explicitly set on it (see the {@link Builder} for details).
 * <p>
 * Note that if a Notification with that id was previously posted it will be replaced. If you
 * need more than one Notification with the same ID you can use a String tag to differentiate
 * them in both the {@link Builder#Builder(Context, String, int, NotificationCompat.Builder)} and
 * {@link NotificationManager#notify(String, int, Notification)}
 * <p>
 * Afterward, {@link OngoingActivity#update(Context, Status) update} can be used to
 * update the status.
 * <p>
 * If saving the {@link OngoingActivity} instance is not convenient, it can be recovered (after the
 * notification is posted) with {@link OngoingActivity#recoverOngoingActivity(Context)}
 * <p>
 * It's worth mentioning that the information provided may be used/redered differently on different
 * SysUIs, so we can only provide a general expectation.
 */
@RequiresApi(24)
public final class OngoingActivity {
    @Nullable
    private final String mTag;
    private final int mNotificationId;
    @Nullable
    private final NotificationCompat.Builder mNotificationBuilder;
    private final OngoingActivityData mData;

    private OngoingActivity(@Nullable String tag,
            int notificationId,
            @NonNull NotificationCompat.Builder notificationBuilder,
            @NonNull OngoingActivityData data) {
        this.mTag = tag;
        this.mNotificationId = notificationId;
        this.mNotificationBuilder = notificationBuilder;
        this.mData = data;
    }

    // Used when reconstructing an OngoingActivity form a bundle.
    OngoingActivity(@NonNull OngoingActivityData data) {
        this.mTag = null;
        this.mNotificationId = 0;
        this.mNotificationBuilder = null;
        this.mData = data;
    }

    /**
     * Builder used to build an {@link OngoingActivity}
     * <p>
     * Note that many fields take a default value from the provided notification if not
     * explicitly set. If set explicitly and in the notification, the value set through the
     * {@link Builder} will be used.
     * <p>
     * The only required fields (set through the builder or the notification) are static icon and
     * pending intent.
     *
     */
    public static final class Builder {
        private final Context mContext;
        private final int mNotificationId;
        private final String mTag;
        private final NotificationCompat.Builder mNotificationBuilder;

        // Ongoing Activity Data
        private Icon mAnimatedIcon;
        private Icon mStaticIcon;
        private Status mStatus;
        private PendingIntent mTouchIntent;
        private LocusIdCompat mLocusId;
        private int mOngoingActivityId = DEFAULT_ID;
        private String mCategory;
        private String mTitle;

        static final int DEFAULT_ID = -1;

        /**
         * Construct a new empty {@link Builder}, associated with the given notification.
         *
         * @param context             to be used during the life of this {@link Builder}, will
         *                            NOT pass a reference into the built {@link OngoingActivity}
         * @param notificationId      id that will be used to post the notification associated
         *                            with this Ongoing Activity
         * @param notificationBuilder builder for the notification associated with this Ongoing
         *                            Activity
         */
        public Builder(@NonNull Context context, int notificationId,
                @NonNull NotificationCompat.Builder notificationBuilder) {
            this(context, null, notificationId, notificationBuilder);
        }

        /**
         * Construct a new empty {@link Builder}, associated with the given notification.
         *
         * @param context             to be used during the life of this {@link Builder}, will
         *                            NOT pass a reference into the built {@link OngoingActivity}
         * @param tag                 tag that will be used to post the notification associated
         *                            with this Ongoing Activity
         * @param notificationId      id that will be used to post the notification associated
         *                            with this Ongoing Activity
         * @param notificationBuilder builder for the notification associated with this Ongoing
         *                            Activity
         */
        public Builder(@NonNull Context context, @NonNull String tag, int notificationId,
                @NonNull NotificationCompat.Builder notificationBuilder) {
            this.mContext = context;
            this.mTag = tag;
            this.mNotificationId = notificationId;
            this.mNotificationBuilder = notificationBuilder;
        }

        /**
         * Set the animated icon that can be used on some surfaces to represent this
         * {@link OngoingActivity}. For example, in the WatchFace.
         * Should be white with a transparent background, preferably an AnimatedVectorDrawable.
         * <p>
         * If not provided, or set to null, the static icon will be used.
         */
        @NonNull
        public Builder setAnimatedIcon(@Nullable Icon animatedIcon) {
            mAnimatedIcon = animatedIcon;
            return this;
        }

        /**
         * Set the animated icon that can be used on some surfaces to represent this
         * {@link OngoingActivity}. For example, in the WatchFace.
         * Should be white with a transparent background, preferably an AnimatedVectorDrawable.
         * <p>
         * If not provided, the static icon will be used.
         */
        @NonNull
        public Builder setAnimatedIcon(@DrawableRes int animatedIcon) {
            mAnimatedIcon = Icon.createWithResource(mContext, animatedIcon);
            return this;
        }

        /**
         * Set the static icon that can be used on some surfaces to represent this
         * {@link OngoingActivity}, for example in the WatchFace in ambient mode.
         * Should be white with a transparent background, preferably a VectorDrawable.
         * <p>
         * If not set, the smallIcon of the notification will be used. If neither is set,
         * {@link Builder#build()} will throw an exception.
         */
        @NonNull
        public Builder setStaticIcon(@NonNull Icon staticIcon) {
            mStaticIcon = staticIcon;
            return this;
        }

        /**
         * Set the static icon that can be used on some surfaces to represent this
         * {@link OngoingActivity}, for example in the WatchFace in ambient mode.
         * Should be white with a transparent background, preferably a VectorDrawable.
         * <p>
         * If not set, the smallIcon of the notification will be used. If neither is set,
         * {@link Builder#build()} will throw an exception.
         */
        @NonNull
        public Builder setStaticIcon(@DrawableRes int staticIcon) {
            mStaticIcon = Icon.createWithResource(mContext, staticIcon);
            return this;
        }

        /**
         * Set the initial status of this ongoing activity, the status may be displayed on the UI to
         * show progress of the Ongoing Activity.
         * <p>
         * If not provided, the contentText of the notification will be used.
         */
        @NonNull
        public Builder setStatus(@NonNull Status status) {
            mStatus = status;
            return this;
        }

        /**
         * Set the intent to be used to go back to the activity when the user interacts with the
         * Ongoing Activity in other surfaces (for example, taps the Icon on the WatchFace).
         * <p>
         * If not set, the contentIntent of the notification will be used. If neither is set,
         * {@link Builder#build()} will throw an exception.
         */
        @NonNull
        public Builder setTouchIntent(@NonNull PendingIntent touchIntent) {
            mTouchIntent = touchIntent;
            return this;
        }

        /**
         * Set the corresponding LocusId of this {@link OngoingActivity}, this will be used by the
         * launcher to identify the corresponding launcher item and display it accordingly.
         * <p>
         * If set to null or not set, the launcher will use heuristics to do the matching.
         */
        @NonNull
        public Builder setLocusId(@Nullable LocusIdCompat locusId) {
            mLocusId = locusId;
            return this;
        }

        /**
         * Give an id to this {@link OngoingActivity}, as a way to reference it in
         * {@link OngoingActivity#recoverOngoingActivity(Context, int)}
         */
        @NonNull
        public Builder setOngoingActivityId(int ongoingActivityId) {
            mOngoingActivityId = ongoingActivityId;
            return this;
        }

        /**
         * Set the category of this {@link OngoingActivity}. It may be used by the system to
         * prioritize displaying the {@link OngoingActivity}.
         * <p>
         * If set, it Must be one of the predefined notification categories (see the
         * {@code CATEGORY_*} constants in {@link NotificationCompat}) that best describes this
         * {@link OngoingActivity}.
         * <p>
         * If this is not set (or null), the notification's category is used if present.
         */
        @NonNull
        public Builder setCategory(@Nullable String category) {
            mCategory = category;
            return this;
        }

        /**
         * Sets the Title of this {@link OngoingActivity}. If this is set to a non-null value, it
         * could be used by the launcher to override the app's title.
         * <p>
         * No defaults from the notification are used for this field.
         */
        @NonNull
        public Builder setTitle(@Nullable String title) {
            mTitle = title;
            return this;
        }

        /**
         * Combine all options provided and the information in the notification if needed,
         * return a new {@link OngoingActivity} object. See particular setters for information on
         * what defaults from the notification are used.
         *
         * @throws IllegalArgumentException if the static icon or the touch intent are not provided.
         */
        @SuppressWarnings("SyntheticAccessor")
        @NonNull
        public OngoingActivity build() {
            Notification notification = mNotificationBuilder.build();
            Icon staticIcon = mStaticIcon == null ? notification.getSmallIcon() : mStaticIcon;
            if (staticIcon == null) {
                throw new IllegalArgumentException("Static icon should be specified.");
            }

            PendingIntent touchIntent = mTouchIntent == null
                    ? notification.contentIntent
                    : mTouchIntent;
            if (touchIntent == null) {
                throw new IllegalArgumentException("Touch intent should be specified.");
            }

            OngoingActivityStatus status = mStatus == null ? null : mStatus.toVersionedParcelable();
            if (status == null) {
                String text = notification.extras.getString(Notification.EXTRA_TEXT);
                if (text != null) {
                    status = Status.forPart(new Status.TextPart(text))
                        .toVersionedParcelable();
                }
            }

            LocusIdCompat locusId = mLocusId;
            if (locusId == null &&  Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                locusId = Api29Impl.getLocusId(notification);
            }

            String category = mCategory == null ? notification.category : mCategory;

            return new OngoingActivity(mTag, mNotificationId, mNotificationBuilder,
                    new OngoingActivityData(
                        mAnimatedIcon,
                        staticIcon,
                        status,
                        touchIntent,
                        locusId == null ? null : locusId.getId(),
                        mOngoingActivityId,
                        category,
                        SystemClock.elapsedRealtime(),
                        mTitle
                    ));
        }
    }

    /**
     * Get the notificationId of the notification associated with this {@link OngoingActivity}.
     */
    public int getNotificationId() {
        return mNotificationId;
    }

    /**
     * Get the tag of the notification associated with this {@link OngoingActivity}, or null if
     * there is none.
     */
    @Nullable
    public String getTag() {
        return mTag;
    }

    /**
     * Get the animated icon that can be used on some surfaces to represent this
     * {@link OngoingActivity}. For example, in the WatchFace.
     */
    @Nullable
    public Icon getAnimatedIcon() {
        return mData.getAnimatedIcon();
    }

    /**
     * Get the static icon that can be used on some surfaces to represent this
     * {@link OngoingActivity}. For example in the WatchFace in ambient mode. If not set, returns
     *  the small icon of the corresponding Notification.
     */
    @NonNull
    public Icon getStaticIcon() {
        return mData.getStaticIcon();
    }

    /**
     * Get the status of this ongoing activity, the status may be displayed on the UI to
     * show progress of the Ongoing Activity. If not set, returns the content text of the
     * corresponding Notification.
     */
    @Nullable
    public Status getStatus() {
        return mData.getStatus() == null ? null :
                Status.fromVersionedParcelable(mData.getStatus());
    }

    /**
     * Get the intent to be used to go back to the activity when the user interacts with the
     * Ongoing Activity in other surfaces (for example, taps the Icon on the WatchFace). If not
     * set, returns the touch intent of the corresponding Notification.
     */
    @NonNull
    public PendingIntent getTouchIntent() {
        return mData.getTouchIntent();
    }

    /**
     * Get the LocusId of this {@link OngoingActivity}, this can be used by the launcher to
     * identify the corresponding launcher item and display it accordingly. If not set, returns
     * the one in the corresponding Notification.
     */
    @Nullable
    public LocusIdCompat getLocusId() {
        return mData.getLocusId();
    }

    /**
     * Get the id to this {@link OngoingActivity}. This id is used to reference it in
     * {@link #recoverOngoingActivity(Context, int)}
     */
    public int getOngoingActivityId() {
        return mData.getOngoingActivityId();
    }

    /**
     * Get the Category of this {@link OngoingActivity} if set, otherwise the category of the
     * corresponding notification.
     */
    @Nullable
    public String getCategory() {
        return mData.getCategory();
    }

    /**
     * Get the time (in {@link SystemClock#elapsedRealtime()} time) the OngoingActivity was built.
     */
    public long getTimestamp() {
        return mData.getTimestamp();
    }

    /**
     * Get the title of this {@link OngoingActivity} if set.
     */
    @Nullable
    public String getTitle() {
        return mData.getTitle();
    }

    /**
     * Notify the system that this activity should be shown as an Ongoing Activity.
     *
     * This will modify the notification builder associated with this Ongoing Activity, so needs
     * to be called before building and posting that notification.
     *
     * @param context May be used to access system services. A reference will not be kept after
     *                this call returns.
     */
    public void apply(@NonNull @SuppressWarnings("unused") Context context) {
        Preconditions.checkNotNull(mNotificationBuilder);
        SerializationHelper.extend(mNotificationBuilder, mData);
    }

    /**
     * Update the status of this Ongoing Activity.
     *
     * Note that this may post the notification updated with the new information.
     *
     * @param context May be used to access system services. A reference will not be kept after
     *                this call returns.
     * @param status  The new status of this Ongoing Activity.
     */
    public void update(@NonNull Context context, @NonNull Status status) {
        Preconditions.checkNotNull(mNotificationBuilder);
        mData.setStatus(status.toVersionedParcelable());
        Notification notification = SerializationHelper.extendAndBuild(mNotificationBuilder, mData);

        NotificationManager manager = context.getSystemService(NotificationManager.class);
        if (mTag == null) {
            manager.notify(mNotificationId, notification);
        } else {
            manager.notify(mTag, mNotificationId, notification);
        }
    }

    /**
     * Convenience method for clients that don’t want to / can’t store the OngoingActivity
     * instance.
     *
     * @param context May be used to access system services. A reference will not be kept after
     *                this call returns.
     * @param filter  used to find the required {@link OngoingActivity}.
     * @return the Ongoing Activity or null if not found
     */
    @Nullable
    public static OngoingActivity recoverOngoingActivity(
            @NonNull Context context,
            @NonNull Predicate<OngoingActivity> filter
    ) {
        StatusBarNotification[] notifications =
                context.getSystemService(NotificationManager.class).getActiveNotifications();
        for (StatusBarNotification statusBarNotification : notifications) {
            OngoingActivityData data =
                    SerializationHelper.createInternal(statusBarNotification.getNotification());
            if (data != null) {
                OngoingActivity oa = new OngoingActivity(
                        statusBarNotification.getTag(),
                        statusBarNotification.getId(),
                        new NotificationCompat.Builder(context,
                                statusBarNotification.getNotification()),
                        data);
                if (filter.test(oa)) {
                    return oa;
                }
            }
        }
        return null;
    }

    /**
     * Convenience method for clients that don’t want to / can’t store the OngoingActivity
     * instance.
     *
     * Note that if there is more than one Ongoing Activity active you have not guarantee
     * over which one you get, you need to use one of the other variations of this method.
     *
     * @param context May be used to access system services. A reference will not be kept after
     *                this call returns.
     * @return the Ongoing Activity or null if not found
     */
    @Nullable
    public static OngoingActivity recoverOngoingActivity(@NonNull Context context) {
        return recoverOngoingActivity(context, (data) -> true);
    }

    /**
     * Convenience method for clients that don’t want to / can’t store the OngoingActivity
     * instance.
     *
     * @param context           May be used to access system services. A reference will not be kept
     *                          after this call returns.
     * @param ongoingActivityId the id of the Ongoing Activity to retrieve, set in
     *                          {@link OngoingActivity.Builder#setOngoingActivityId(int)}
     * @return the Ongoing Activity or null if not found
     */
    @Nullable
    public static OngoingActivity recoverOngoingActivity(@NonNull Context context,
            int ongoingActivityId) {
        return recoverOngoingActivity(context,
                (data) -> data.getOngoingActivityId() == ongoingActivityId);
    }


    // Inner class required to avoid VFY errors during class init.
    @RequiresApi(29)
    static class Api29Impl {
        // Avoid instantiation.
        private Api29Impl() {
        }

        @Nullable
        private static LocusIdCompat getLocusId(@NonNull Notification notification) {
            return notification.getLocusId() != null
                    ? LocusIdCompat.toLocusIdCompat(notification.getLocusId()) : null;
        }
    }
}