/*
 * Copyright (C) 2019 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;
import static android.service.watchdog.ExplicitHealthCheckService.EXTRA_HEALTH_CHECK_PASSED_PACKAGE;
import static android.service.watchdog.ExplicitHealthCheckService.EXTRA_REQUESTED_PACKAGES;
import static android.service.watchdog.ExplicitHealthCheckService.EXTRA_SUPPORTED_PACKAGES;
import static android.service.watchdog.ExplicitHealthCheckService.PackageConfig;

import android.Manifest;
import android.annotation.MainThread;
import android.annotation.Nullable;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.pm.ServiceInfo;
import android.os.IBinder;
import android.os.RemoteCallback;
import android.os.RemoteException;
import android.os.UserHandle;
import android.service.watchdog.ExplicitHealthCheckService;
import android.service.watchdog.IExplicitHealthCheckService;
import android.text.TextUtils;
import android.util.ArraySet;
import android.util.Slog;

import com.android.internal.annotations.GuardedBy;
import com.android.internal.util.Preconditions;

import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;

// TODO(b/120598832): Add tests
/**
 * Controls the connections with {@link ExplicitHealthCheckService}.
 */
class ExplicitHealthCheckController {
    private static final String TAG = "ExplicitHealthCheckController";
    private final Object mLock = new Object();
    private final Context mContext;

    // Called everytime a package passes the health check, so the watchdog is notified of the
    // passing check. In practice, should never be null after it has been #setEnabled.
    // To prevent deadlocks between the controller and watchdog threads, we have
    // a lock invariant to ALWAYS acquire the PackageWatchdog#mLock before #mLock in this class.
    // It's easier to just NOT hold #mLock when calling into watchdog code on this consumer.
    @GuardedBy("mLock") @Nullable private Consumer<String> mPassedConsumer;
    // Called everytime after a successful #syncRequest call, so the watchdog can receive packages
    // supporting health checks and update its internal state. In practice, should never be null
    // after it has been #setEnabled.
    // To prevent deadlocks between the controller and watchdog threads, we have
    // a lock invariant to ALWAYS acquire the PackageWatchdog#mLock before #mLock in this class.
    // It's easier to just NOT hold #mLock when calling into watchdog code on this consumer.
    @GuardedBy("mLock") @Nullable private Consumer<List<PackageConfig>> mSupportedConsumer;
    // Called everytime we need to notify the watchdog to sync requests between itself and the
    // health check service. In practice, should never be null after it has been #setEnabled.
    // To prevent deadlocks between the controller and watchdog threads, we have
    // a lock invariant to ALWAYS acquire the PackageWatchdog#mLock before #mLock in this class.
    // It's easier to just NOT hold #mLock when calling into watchdog code on this runnable.
    @GuardedBy("mLock") @Nullable private Runnable mNotifySyncRunnable;
    // Actual binder object to the explicit health check service.
    @GuardedBy("mLock") @Nullable private IExplicitHealthCheckService mRemoteService;
    // Connection to the explicit health check service, necessary to unbind.
    // We should only try to bind if mConnection is null, non-null indicates we
    // are connected or at least connecting.
    @GuardedBy("mLock") @Nullable private ServiceConnection mConnection;
    // Bind state of the explicit health check service.
    @GuardedBy("mLock") private boolean mEnabled;

    ExplicitHealthCheckController(Context context) {
        mContext = context;
    }

    /** Enables or disables explicit health checks. */
    public void setEnabled(boolean enabled) {
        synchronized (mLock) {
            Slog.i(TAG, "Explicit health checks " + (enabled ? "enabled." : "disabled."));
            mEnabled = enabled;
        }
    }

    /**
     * Sets callbacks to listen to important events from the controller.
     *
     * <p> Should be called once at initialization before any other calls to the controller to
     * ensure a happens-before relationship of the set parameters and visibility on other threads.
     */
    public void setCallbacks(Consumer<String> passedConsumer,
            Consumer<List<PackageConfig>> supportedConsumer, Runnable notifySyncRunnable) {
        synchronized (mLock) {
            if (mPassedConsumer != null || mSupportedConsumer != null
                    || mNotifySyncRunnable != null) {
                Slog.wtf(TAG, "Resetting health check controller callbacks");
            }

            mPassedConsumer = Preconditions.checkNotNull(passedConsumer);
            mSupportedConsumer = Preconditions.checkNotNull(supportedConsumer);
            mNotifySyncRunnable = Preconditions.checkNotNull(notifySyncRunnable);
        }
    }

    /**
     * Calls the health check service to request or cancel packages based on
     * {@code newRequestedPackages}.
     *
     * <p> Supported packages in {@code newRequestedPackages} that have not been previously
     * requested will be requested while supported packages not in {@code newRequestedPackages}
     * but were previously requested will be cancelled.
     *
     * <p> This handles binding and unbinding to the health check service as required.
     *
     * <p> Note, calling this may modify {@code newRequestedPackages}.
     *
     * <p> Note, this method is not thread safe, all calls should be serialized.
     */
    public void syncRequests(Set<String> newRequestedPackages) {
        boolean enabled;
        synchronized (mLock) {
            enabled = mEnabled;
        }

        if (!enabled) {
            Slog.i(TAG, "Health checks disabled, no supported packages");
            // Call outside lock
            mSupportedConsumer.accept(Collections.emptyList());
            return;
        }

        getSupportedPackages(supportedPackageConfigs -> {
            // Notify the watchdog without lock held
            mSupportedConsumer.accept(supportedPackageConfigs);
            getRequestedPackages(previousRequestedPackages -> {
                synchronized (mLock) {
                    // Hold lock so requests and cancellations are sent atomically.
                    // It is important we don't mix requests from multiple threads.

                    Set<String> supportedPackages = new ArraySet<>();
                    for (PackageConfig config : supportedPackageConfigs) {
                        supportedPackages.add(config.getPackageName());
                    }
                    // Note, this may modify newRequestedPackages
                    newRequestedPackages.retainAll(supportedPackages);

                    // Cancel packages no longer requested
                    actOnDifference(previousRequestedPackages,
                            newRequestedPackages, p -> cancel(p));
                    // Request packages not yet requested
                    actOnDifference(newRequestedPackages,
                            previousRequestedPackages, p -> request(p));

                    if (newRequestedPackages.isEmpty()) {
                        Slog.i(TAG, "No more health check requests, unbinding...");
                        unbindService();
                        return;
                    }
                }
            });
        });
    }

    private void actOnDifference(Collection<String> collection1, Collection<String> collection2,
            Consumer<String> action) {
        Iterator<String> iterator = collection1.iterator();
        while (iterator.hasNext()) {
            String packageName = iterator.next();
            if (!collection2.contains(packageName)) {
                action.accept(packageName);
            }
        }
    }

    /**
     * Requests an explicit health check for {@code packageName}.
     * After this request, the callback registered on {@link #setCallbacks} can receive explicit
     * health check passed results.
     */
    private void request(String packageName) {
        synchronized (mLock) {
            if (!prepareServiceLocked("request health check for " + packageName)) {
                return;
            }

            Slog.i(TAG, "Requesting health check for package " + packageName);
            try {
                mRemoteService.request(packageName);
            } catch (RemoteException e) {
                Slog.w(TAG, "Failed to request health check for package " + packageName, e);
            }
        }
    }

    /**
     * Cancels all explicit health checks for {@code packageName}.
     * After this request, the callback registered on {@link #setCallbacks} can no longer receive
     * explicit health check passed results.
     */
    private void cancel(String packageName) {
        synchronized (mLock) {
            if (!prepareServiceLocked("cancel health check for " + packageName)) {
                return;
            }

            Slog.i(TAG, "Cancelling health check for package " + packageName);
            try {
                mRemoteService.cancel(packageName);
            } catch (RemoteException e) {
                // Do nothing, if the service is down, when it comes up, we will sync requests,
                // if there's some other error, retrying wouldn't fix anyways.
                Slog.w(TAG, "Failed to cancel health check for package " + packageName, e);
            }
        }
    }

    /**
     * Returns the packages that we can request explicit health checks for.
     * The packages will be returned to the {@code consumer}.
     */
    private void getSupportedPackages(Consumer<List<PackageConfig>> consumer) {
        synchronized (mLock) {
            if (!prepareServiceLocked("get health check supported packages")) {
                return;
            }

            Slog.d(TAG, "Getting health check supported packages");
            try {
                mRemoteService.getSupportedPackages(new RemoteCallback(result -> {
                    List<PackageConfig> packages =
                            result.getParcelableArrayList(EXTRA_SUPPORTED_PACKAGES);
                    Slog.i(TAG, "Explicit health check supported packages " + packages);
                    consumer.accept(packages);
                }));
            } catch (RemoteException e) {
                // Request failed, treat as if all observed packages are supported, if any packages
                // expire during this period, we may incorrectly treat it as failing health checks
                // even if we don't support health checks for the package.
                Slog.w(TAG, "Failed to get health check supported packages", e);
            }
        }
    }

    /**
     * Returns the packages for which health checks are currently in progress.
     * The packages will be returned to the {@code consumer}.
     */
    private void getRequestedPackages(Consumer<List<String>> consumer) {
        synchronized (mLock) {
            if (!prepareServiceLocked("get health check requested packages")) {
                return;
            }

            Slog.d(TAG, "Getting health check requested packages");
            try {
                mRemoteService.getRequestedPackages(new RemoteCallback(result -> {
                    List<String> packages = result.getStringArrayList(EXTRA_REQUESTED_PACKAGES);
                    Slog.i(TAG, "Explicit health check requested packages " + packages);
                    consumer.accept(packages);
                }));
            } catch (RemoteException e) {
                // Request failed, treat as if we haven't requested any packages, if any packages
                // were actually requested, they will not be cancelled now. May be cancelled later
                Slog.w(TAG, "Failed to get health check requested packages", e);
            }
        }
    }

    /**
     * Binds to the explicit health check service if the controller is enabled and
     * not already bound.
     */
    private void bindService() {
        synchronized (mLock) {
            if (!mEnabled || mConnection != null || mRemoteService != null) {
                if (!mEnabled) {
                    Slog.i(TAG, "Not binding to service, service disabled");
                } else if (mRemoteService != null) {
                    Slog.i(TAG, "Not binding to service, service already connected");
                } else {
                    Slog.i(TAG, "Not binding to service, service already connecting");
                }
                return;
            }
            ComponentName component = getServiceComponentNameLocked();
            if (component == null) {
                Slog.wtf(TAG, "Explicit health check service not found");
                return;
            }

            Intent intent = new Intent();
            intent.setComponent(component);
            mConnection = new ServiceConnection() {
                @Override
                public void onServiceConnected(ComponentName name, IBinder service) {
                    Slog.i(TAG, "Explicit health check service is connected " + name);
                    initState(service);
                }

                @Override
                @MainThread
                public void onServiceDisconnected(ComponentName name) {
                    // Service crashed or process was killed, #onServiceConnected will be called.
                    // Don't need to re-bind.
                    Slog.i(TAG, "Explicit health check service is disconnected " + name);
                    synchronized (mLock) {
                        mRemoteService = null;
                    }
                }

                @Override
                public void onBindingDied(ComponentName name) {
                    // Application hosting service probably got updated
                    // Need to re-bind.
                    Slog.i(TAG, "Explicit health check service binding is dead. Rebind: " + name);
                    unbindService();
                    bindService();
                }

                @Override
                public void onNullBinding(ComponentName name) {
                    // Should never happen. Service returned null from #onBind.
                    Slog.wtf(TAG, "Explicit health check service binding is null?? " + name);
                }
            };

            mContext.bindServiceAsUser(intent, mConnection,
                    Context.BIND_AUTO_CREATE, UserHandle.of(UserHandle.USER_SYSTEM));
            Slog.i(TAG, "Explicit health check service is bound");
        }
    }

    /** Unbinds the explicit health check service. */
    private void unbindService() {
        synchronized (mLock) {
            if (mRemoteService != null) {
                mContext.unbindService(mConnection);
                mRemoteService = null;
                mConnection = null;
            }
            Slog.i(TAG, "Explicit health check service is unbound");
        }
    }

    @GuardedBy("mLock")
    @Nullable
    private ServiceInfo getServiceInfoLocked() {
        final String packageName =
                mContext.getPackageManager().getServicesSystemSharedLibraryPackageName();
        if (packageName == null) {
            Slog.w(TAG, "no external services package!");
            return null;
        }

        final Intent intent = new Intent(ExplicitHealthCheckService.SERVICE_INTERFACE);
        intent.setPackage(packageName);
        final ResolveInfo resolveInfo = mContext.getPackageManager().resolveService(intent,
                PackageManager.GET_SERVICES | PackageManager.GET_META_DATA);
        if (resolveInfo == null || resolveInfo.serviceInfo == null) {
            Slog.w(TAG, "No valid components found.");
            return null;
        }
        return resolveInfo.serviceInfo;
    }

    @GuardedBy("mLock")
    @Nullable
    private ComponentName getServiceComponentNameLocked() {
        final ServiceInfo serviceInfo = getServiceInfoLocked();
        if (serviceInfo == null) {
            return null;
        }

        final ComponentName name = new ComponentName(serviceInfo.packageName, serviceInfo.name);
        if (!Manifest.permission.BIND_EXPLICIT_HEALTH_CHECK_SERVICE
                .equals(serviceInfo.permission)) {
            Slog.w(TAG, name.flattenToShortString() + " does not require permission "
                    + Manifest.permission.BIND_EXPLICIT_HEALTH_CHECK_SERVICE);
            return null;
        }
        return name;
    }

    private void initState(IBinder service) {
        synchronized (mLock) {
            if (!mEnabled) {
                Slog.w(TAG, "Attempting to connect disabled service?? Unbinding...");
                // Very unlikely, but we disabled the service after binding but before we connected
                unbindService();
                return;
            }
            mRemoteService = IExplicitHealthCheckService.Stub.asInterface(service);
            try {
                mRemoteService.setCallback(new RemoteCallback(result -> {
                    String packageName = result.getString(EXTRA_HEALTH_CHECK_PASSED_PACKAGE);
                    if (!TextUtils.isEmpty(packageName)) {
                        if (mPassedConsumer == null) {
                            Slog.wtf(TAG, "Health check passed for package " + packageName
                                    + "but no consumer registered.");
                        } else {
                            // Call without lock held
                            mPassedConsumer.accept(packageName);
                        }
                    } else {
                        Slog.wtf(TAG, "Empty package passed explicit health check?");
                    }
                }));
                Slog.i(TAG, "Service initialized, syncing requests");
            } catch (RemoteException e) {
                Slog.wtf(TAG, "Could not setCallback on explicit health check service");
            }
        }
        // Calling outside lock
        mNotifySyncRunnable.run();
    }

    /**
     * Prepares the health check service to receive requests.
     *
     * @return {@code true} if it is ready and we can proceed with a request,
     * {@code false} otherwise. If it is not ready, and the service is enabled,
     * we will bind and the request should be automatically attempted later.
     */
    @GuardedBy("mLock")
    private boolean prepareServiceLocked(String action) {
        if (mRemoteService != null && mEnabled) {
            return true;
        }
        Slog.i(TAG, "Service not ready to " + action
                + (mEnabled ? ". Binding..." : ". Disabled"));
        if (mEnabled) {
            bindService();
        }
        return false;
    }
}
