TimeGatewayImpl.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 android.os.Handler;
import android.os.SystemClock;

import androidx.annotation.NonNull;
import androidx.annotation.RestrictTo;
import androidx.annotation.RestrictTo.Scope;
import androidx.annotation.UiThread;
import androidx.collection.ArrayMap;

import java.util.Map;
import java.util.concurrent.Executor;

/**
 * Default implementation of {@link TimeGateway} using Android's clock.
 *
 * @hide
 */
@RestrictTo(Scope.LIBRARY_GROUP)
public final class TimeGatewayImpl implements TimeGateway, AutoCloseable {
    private final Handler uiHandler;
    private final Map<TimeCallback, Executor> registeredCallbacks = new ArrayMap<>();
    private boolean updatesEnabled;
    private final Runnable onTick;

    private long lastScheduleTimeMillis = 0;

    // Suppress warning on "onTick = this::notifyNextSecond". This happens because notifyNextSecond
    // is @UnderInitialization here, but onTick needs to be @Initialized. This is safe though;  the
    // only time that onTick can be invoked is in the other methods on this class, which can only be
    // called after initialization is complete. This class is also final, so those methods cannot be
    // called from a sub-constructor either.
    @SuppressWarnings("methodref.receiver.bound")
    public TimeGatewayImpl(@NonNull Handler uiHandler, boolean updatesEnabled) {
        this.uiHandler = uiHandler;
        this.updatesEnabled = updatesEnabled;

        this.onTick = this::notifyNextSecond;
    }

    /** See {@link TimeGateway#registerForUpdates(Executor, TimeCallback)}. */
    @Override
    public void registerForUpdates(@NonNull Executor executor, @NonNull TimeCallback callback) {
        registeredCallbacks.put(callback, executor);

        // If this was the first registration, _and_ we're enabled, then schedule the message on the
        // Handler (otherwise, another call has already scheduled the call).
        if (registeredCallbacks.size() == 1 && this.updatesEnabled) {
            lastScheduleTimeMillis = SystemClock.uptimeMillis() + 1000;
            uiHandler.postAtTime(this.onTick, this, lastScheduleTimeMillis);
        }

        // Send first update to initialize clients that are using this TimeGateway
        if (updatesEnabled) {
            callback.onPreUpdate();
            callback.onData();
        }
    }

    /** See {@link TimeGateway#unregisterForUpdates(TimeCallback)}. */
    @Override
    public void unregisterForUpdates(@NonNull TimeCallback callback) {
        registeredCallbacks.remove(callback);

        // If there are no more registered callbacks, stop the periodic call.
        if (registeredCallbacks.isEmpty() && this.updatesEnabled) {
            uiHandler.removeCallbacks(this.onTick, this);
        }
    }

    @UiThread
    public void enableUpdates() {
        setUpdatesEnabled(true);
    }

    @UiThread
    public void disableUpdates() {
        setUpdatesEnabled(false);
    }

    private void setUpdatesEnabled(boolean updatesEnabled) {
        if (updatesEnabled == this.updatesEnabled) {
            return;
        }

        this.updatesEnabled = updatesEnabled;

        if (!updatesEnabled) {
            uiHandler.removeCallbacks(this.onTick, this);
        } else if (!registeredCallbacks.isEmpty()) {
            lastScheduleTimeMillis = SystemClock.uptimeMillis() + 1000;

            uiHandler.postAtTime(this.onTick, this, lastScheduleTimeMillis);
        }
    }

    @SuppressWarnings("ExecutorTaskName")
    private void notifyNextSecond() {
        if (!this.updatesEnabled) {
            return;
        }

        for (Map.Entry<TimeCallback, Executor> callback : registeredCallbacks.entrySet()) {
            callback.getValue().execute(callback.getKey()::onPreUpdate);
        }

        for (Map.Entry<TimeCallback, Executor> callback : registeredCallbacks.entrySet()) {
            callback.getValue().execute(callback.getKey()::onData);
        }

        lastScheduleTimeMillis += 1000;

        // Ensure that the new time is actually in the future. If a call from uiHandler gets
        // significantly delayed for any reason, then without this, we'll reschedule immediately
        // (potentially multiple times), compounding the situation further.
        if (lastScheduleTimeMillis < SystemClock.uptimeMillis()) {
            // Skip the failed updates...
            long missedTime = SystemClock.uptimeMillis() - lastScheduleTimeMillis;

            // Round up to the nearest second...
            missedTime = ((missedTime / 1000) + 1) * 1000;
            lastScheduleTimeMillis += missedTime;
        }

        uiHandler.postAtTime(this.onTick, this, lastScheduleTimeMillis);
    }

    @Override
    public void close() {
        setUpdatesEnabled(false);
        registeredCallbacks.clear();
    }
}