/*
 * Copyright (C) 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 com.android.server.timedetector;

import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.RequiresPermission;
import android.app.AlarmManager;
import android.app.timedetector.GnssTimeSuggestion;
import android.app.timedetector.TimeDetector;
import android.content.Context;
import android.location.LocationListener;
import android.location.LocationManager;
import android.location.LocationManagerInternal;
import android.location.LocationRequest;
import android.location.LocationTime;
import android.os.Binder;
import android.os.Handler;
import android.os.ResultReceiver;
import android.os.ShellCallback;
import android.os.SystemClock;
import android.os.TimestampedValue;
import android.util.LocalLog;
import android.util.Log;

import com.android.internal.annotations.GuardedBy;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.util.DumpUtils;
import com.android.server.FgThread;
import com.android.server.LocalServices;
import com.android.server.SystemService;

import java.io.FileDescriptor;
import java.io.PrintWriter;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.Executor;

/**
 * Monitors the GNSS time.
 *
 * <p>When available, the time is always suggested to the {@link
 * com.android.server.timedetector.TimeDetectorService} where it may be used to set the device
 * system clock, depending on user settings and what other signals are available.
 */
public final class GnssTimeUpdateService extends Binder {
    private static final String TAG = "GnssTimeUpdateService";
    private static final boolean D = Log.isLoggable(TAG, Log.DEBUG);

    /**
     * Handles the lifecycle events for the GnssTimeUpdateService.
     */
    public static class Lifecycle extends SystemService {
        private GnssTimeUpdateService mService;

        public Lifecycle(@NonNull Context context) {
            super(context);
        }

        @Override
        public void onStart() {
            Context context = getContext().createAttributionContext(ATTRIBUTION_TAG);
            AlarmManager alarmManager = context.getSystemService(AlarmManager.class);
            LocationManager locationManager = context.getSystemService(LocationManager.class);
            LocationManagerInternal locationManagerInternal =
                    LocalServices.getService(LocationManagerInternal.class);
            TimeDetector timeDetector = context.getSystemService(TimeDetector.class);

            mService = new GnssTimeUpdateService(context, alarmManager, locationManager,
                    locationManagerInternal, timeDetector);
            publishBinderService("gnss_time_update_service", mService);
        }

        @Override
        public void onBootPhase(int phase) {
            // Need to wait for some location providers to be enabled. If done at
            // PHASE_SYSTEM_SERVICES_READY, error where "gps" provider does not exist could occur.
            if (phase == PHASE_THIRD_PARTY_APPS_CAN_START) {
                // Initiate location updates. On boot, GNSS might not be available right away.
                // Instead of polling GNSS time periodically, passive location updates are enabled.
                // Once an update is received, the gnss time will be queried and suggested to
                // TimeDetectorService.
                mService.startGnssListeningInternal();
            }
        }
    }

    private static final Duration GNSS_TIME_UPDATE_ALARM_INTERVAL = Duration.ofHours(4);
    private static final String ATTRIBUTION_TAG = "GnssTimeUpdateService";

    /**
     * A log that records the decisions to fetch a GNSS time update.
     * This is logged in bug reports to assist with debugging issues with GNSS time suggestions.
     */
    private final LocalLog mLocalLog = new LocalLog(10, false /* useLocalTimestamps */);
    /** The executor used for async operations */
    private final Executor mExecutor = FgThread.getExecutor();
    /** The handler used for async operations */
    private final Handler mHandler = FgThread.getHandler();

    private final Context mContext;
    private final TimeDetector mTimeDetector;
    private final AlarmManager mAlarmManager;
    private final LocationManager mLocationManager;
    private final LocationManagerInternal mLocationManagerInternal;


    private final Object mLock = new Object();
    @GuardedBy("mLock") @Nullable private AlarmManager.OnAlarmListener mAlarmListener;
    @GuardedBy("mLock") @Nullable private LocationListener mLocationListener;

    @Nullable private volatile TimestampedValue<Long> mLastSuggestedGnssTime;

    @VisibleForTesting
    GnssTimeUpdateService(@NonNull Context context, @NonNull AlarmManager alarmManager,
            @NonNull LocationManager locationManager,
            @NonNull LocationManagerInternal locationManagerInternal,
            @NonNull TimeDetector timeDetector) {
        mContext = Objects.requireNonNull(context);
        mAlarmManager = Objects.requireNonNull(alarmManager);
        mLocationManager = Objects.requireNonNull(locationManager);
        mLocationManagerInternal = Objects.requireNonNull(locationManagerInternal);
        mTimeDetector = Objects.requireNonNull(timeDetector);
    }

    /**
     * Used by {@link com.android.server.timedetector.GnssTimeUpdateServiceShellCommand} to force
     * the service into GNSS listening mode.
     */
    @RequiresPermission(android.Manifest.permission.SET_TIME)
    boolean startGnssListening() {
        mContext.enforceCallingPermission(
                android.Manifest.permission.SET_TIME, "Start GNSS listening");
        mLocalLog.log("startGnssListening() called");

        final long token = Binder.clearCallingIdentity();
        try {
            return startGnssListeningInternal();
        } finally {
            Binder.restoreCallingIdentity(token);
        }
    }

    /**
     * Starts listening for passive location updates. Such a request will not trigger any active
     * locations or power usage itself. Returns {@code true} if the service is listening after the
     * method returns and {@code false} otherwise. At present this method only returns {@code false}
     * if there is no GPS provider on the device.
     *
     * <p>If the service is already listening for locations this is a no-op. If the device is in a
     * "sleeping" state between listening periods then it will return to listening.
     */
    @VisibleForTesting
    boolean startGnssListeningInternal() {
        if (!mLocationManager.hasProvider(LocationManager.GPS_PROVIDER)) {
            logError("GPS provider does not exist on this device");
            return false;
        }

        synchronized (mLock) {
            if (mLocationListener != null) {
                logDebug("Already listening for GNSS updates");
                return true;
            }

            // If startGnssListening() is called during manual tests to jump back into location
            // listening then there will usually be an alarm set.
            if (mAlarmListener != null) {
                mAlarmManager.cancel(mAlarmListener);
                mAlarmListener = null;
            }

            startGnssListeningLocked();
            return true;
        }
    }

    @GuardedBy("mLock")
    private void startGnssListeningLocked() {
        logDebug("startGnssListeningLocked()");

        // Location Listener triggers onLocationChanged() when GNSS data is available, so
        // that the getGnssTimeMillis() function doesn't need to be continuously polled.
        mLocationListener = location -> handleLocationAvailable();
        mLocationManager.requestLocationUpdates(
                LocationManager.GPS_PROVIDER,
                new LocationRequest.Builder(LocationRequest.PASSIVE_INTERVAL)
                        .setMinUpdateIntervalMillis(0)
                        .build(),
                mExecutor,
                mLocationListener);
    }

    private void handleLocationAvailable() {
        logDebug("handleLocationAvailable()");

        // getGnssTimeMillis() can return null when the Master Location Switch for the
        // foreground user is disabled.
        LocationTime locationTime = mLocationManagerInternal.getGnssTimeMillis();
        if (locationTime != null) {
            String msg = "Passive location time received: " + locationTime;
            logDebug(msg);
            mLocalLog.log(msg);
            suggestGnssTime(locationTime);
        } else {
            logDebug("getGnssTimeMillis() returned null");
        }

        synchronized (mLock) {
            if (mLocationListener == null) {
                logWarning("mLocationListener unexpectedly null");
            } else {
                mLocationManager.removeUpdates(mLocationListener);
                mLocationListener = null;
            }

            if (mAlarmListener != null) {
                logWarning("mAlarmListener was unexpectedly non-null");
                mAlarmManager.cancel(mAlarmListener);
            }

            // Set next alarm to re-enable location updates.
            long next = SystemClock.elapsedRealtime()
                    + GNSS_TIME_UPDATE_ALARM_INTERVAL.toMillis();
            mAlarmListener = this::handleAlarmFired;
            mAlarmManager.set(
                    AlarmManager.ELAPSED_REALTIME_WAKEUP,
                    next,
                    TAG,
                    mAlarmListener,
                    mHandler);
        }
    }

    private void handleAlarmFired() {
        logDebug("handleAlarmFired()");

        synchronized (mLock) {
            mAlarmListener = null;
            startGnssListeningLocked();
        }
    }

    /**
     * Convert LocationTime to TimestampedValue. Then suggest TimestampedValue to Time Detector.
     */
    private void suggestGnssTime(LocationTime locationTime) {
        logDebug("suggestGnssTime()");

        long gnssUnixEpochTimeMillis = locationTime.getUnixEpochTimeMillis();
        long elapsedRealtimeMs = locationTime.getElapsedRealtimeNanos() / 1_000_000L;

        TimestampedValue<Long> timeSignal =
                new TimestampedValue<>(elapsedRealtimeMs, gnssUnixEpochTimeMillis);
        mLastSuggestedGnssTime = timeSignal;

        GnssTimeSuggestion timeSuggestion = new GnssTimeSuggestion(timeSignal);
        mTimeDetector.suggestGnssTime(timeSuggestion);
    }

    @Override
    protected void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
        if (!DumpUtils.checkDumpPermission(mContext, TAG, pw)) return;
        pw.println("mLastSuggestedGnssTime: " + mLastSuggestedGnssTime);
        synchronized (mLock) {
            pw.print("state: ");
            if (mLocationListener != null) {
                pw.println("time updates enabled");
            } else {
                pw.println("alarm enabled");
            }
        }
        pw.println("Log:");
        mLocalLog.dump(pw);
    }

    @Override
    public void onShellCommand(FileDescriptor in, FileDescriptor out, FileDescriptor err,
            String[] args, ShellCallback callback, ResultReceiver resultReceiver) {
        new GnssTimeUpdateServiceShellCommand(this).exec(
                this, in, out, err, args, callback, resultReceiver);
    }

    private void logError(String msg) {
        Log.e(TAG, msg);
        mLocalLog.log(msg);
    }

    private void logWarning(String msg) {
        Log.w(TAG, msg);
        mLocalLog.log(msg);
    }

    private void logDebug(String msg) {
        if (D) {
            Log.d(TAG, msg);
        }
    }
}
