/*
 * Copyright (C) 2017 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.timezone.updater;

import android.app.timezone.Callback;
import android.app.timezone.DistroFormatVersion;
import android.app.timezone.DistroRulesVersion;
import android.app.timezone.RulesManager;
import android.app.timezone.RulesState;
import android.app.timezone.RulesUpdaterContract;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ProviderInfo;
import android.database.Cursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.os.UserHandle;
import android.provider.TimeZoneRulesDataContract;
import android.util.Log;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import libcore.io.Streams;

/**
 * A broadcast receiver triggered by an
 * {@link RulesUpdaterContract#ACTION_TRIGGER_RULES_UPDATE_CHECK intent} from the system server in
 * response to the installation/replacement/uninstallation of a time zone data app.
 *
 * <p>The trigger intent contains a {@link RulesUpdaterContract#EXTRA_CHECK_TOKEN byte[] check
 * token} which must be returned to the system server {@link RulesManager} API via one of the
 * {@link RulesManager#requestInstall(ParcelFileDescriptor, byte[], Callback) install},
 * {@link RulesManager#requestUninstall(byte[], Callback)} or
 * {@link RulesManager#requestNothing(byte[], boolean)} methods.
 *
 * <p>The RulesCheckReceiver is responsible for handling the operation requested by the data app.
 * The data app makes its payload available via a {@link TimeZoneRulesDataContract specified}
 * {@link android.content.ContentProvider} with the URI {@link TimeZoneRulesDataContract#AUTHORITY}.
 *
 * <p>If the {@link TimeZoneRulesDataContract.Operation#COLUMN_TYPE operation type} is an
 * {@link TimeZoneRulesDataContract.Operation#TYPE_INSTALL install request}, then the time zone data
 * format {@link TimeZoneRulesDataContract.Operation#COLUMN_DISTRO_MAJOR_VERSION major version} and
 * {@link TimeZoneRulesDataContract.Operation#COLUMN_DISTRO_MINOR_VERSION minor version}, the
 * {@link TimeZoneRulesDataContract.Operation#COLUMN_RULES_VERSION IANA rules version}, and the
 * {@link TimeZoneRulesDataContract.Operation#COLUMN_REVISION revision} are checked to see if they
 * can be applied to the device. If the data is valid the {@link RulesCheckReceiver} will obtain
 * the payload from the data app content provider via
 * {@link android.content.ContentProvider#openFile(Uri, String)} and pass the data to the system
 * server for installation via the
 * {@link RulesManager#requestInstall(ParcelFileDescriptor, byte[], Callback)}.
 */
public class RulesCheckReceiver extends BroadcastReceiver {
    final static String TAG = "RulesCheckReceiver";

    private RulesManager mRulesManager;

    @Override
    public void onReceive(Context context, Intent intent) {
        // No need to make this synchronized, onReceive() is called on the main thread, there's no
        // important object state that could be corrupted and the check token allows for ordering
        // issues.
        if (!RulesUpdaterContract.ACTION_TRIGGER_RULES_UPDATE_CHECK.equals(intent.getAction())) {
            // Unknown. Do nothing.
            Log.w(TAG, "Unrecognized intent action received: " + intent
                    + ", action=" + intent.getAction());
            return;
        }

        // The time zone update process should run as the system user exclusively as it's a
        // system feature, not user dependent.
        UserHandle currentUserHandle = android.os.Process.myUserHandle();
        if (!currentUserHandle.isSystem()) {
            // Just do nothing.
            Log.w(TAG, "Supposed to be running as the system user,"
                    + " instead running as user=" + currentUserHandle);
            return;
        }

        mRulesManager = (RulesManager) context.getSystemService("timezone");

        byte[] token = intent.getByteArrayExtra(RulesUpdaterContract.EXTRA_CHECK_TOKEN);
        EventLogTags.writeTimezoneCheckTriggerReceived(Arrays.toString(token));

        if (shouldUninstallCurrentInstall(context)) {
            Log.i(TAG, "Device should be returned to having no time zone distro installed, issuing"
                    + " uninstall request");
            // Uninstall is a no-op if nothing is installed.
            handleUninstall(token);
            return;
        }

        // Note: We rely on the system server to check that the configured data application is the
        // one that exposes the content provider with the well-known authority, and is a privileged
        // application as required. It is *not* checked here and it is assumed the updater can trust
        // the data application.

        // Obtain the information about what the data app is telling us to do.
        DistroOperation operation = getOperation(context, token);
        if (operation == null) {
            Log.w(TAG, "Unable to read time zone operation. Halting check.");
            boolean success = true; // No point in retrying.
            handleCheckComplete(token, success);
            return;
        }

        // Try to do what the data app asked.
        Log.d(TAG, "Time zone operation: " + operation + " received.");
        switch (operation.mType) {
            case TimeZoneRulesDataContract.Operation.TYPE_NO_OP:
                // No-op. Just acknowledge the check.
                handleCheckComplete(token, true /* success */);
                break;
            case TimeZoneRulesDataContract.Operation.TYPE_UNINSTALL:
                handleUninstall(token);
                break;
            case TimeZoneRulesDataContract.Operation.TYPE_INSTALL:
                handleCopyAndInstall(context, token, operation.mDistroFormatVersion,
                        operation.mDistroRulesVersion);
                break;
            default:
                Log.w(TAG, "Unknown time zone operation: " + operation
                        + " received. Halting check.");
                final boolean success = true; // No point in retrying.
                handleCheckComplete(token, success);
        }
    }

    private boolean shouldUninstallCurrentInstall(Context context) {
        int flags = PackageManager.MATCH_DISABLED_UNTIL_USED_COMPONENTS;
        PackageManager packageManager = context.getPackageManager();
        ProviderInfo providerInfo =
                packageManager.resolveContentProvider(TimeZoneRulesDataContract.AUTHORITY, flags);
        if (providerInfo == null || providerInfo.applicationInfo == null) {
            Log.w(TAG, "No package/application info available for content provider "
                    + TimeZoneRulesDataContract.AUTHORITY);
            // Something has gone wrong. Trying to return the device to clean is a reasonable
            // response.
            return true;
        }

        // If the data app is the one from /system, we can treat this as "uninstall": if nothing
        // is installed then the system will treat this as a no-op, and if something is installed
        // this will stage an uninstall.
        // We could install the distro from an app contained in the system image but we assume it's
        // going to contain the same time zone data as the base version and would be a no op.

        ApplicationInfo applicationInfo = providerInfo.applicationInfo;
        // isPrivilegedApp() => initial install directory for app /system/priv-app (required)
        // isUpdatedSystemApp() => app has been replaced by an updated version that resides in /data
        return applicationInfo.isPrivilegedApp() && !applicationInfo.isUpdatedSystemApp();
    }

    private DistroOperation getOperation(Context context, byte[] tokenBytes) {
        EventLogTags.writeTimezoneCheckReadFromDataApp(Arrays.toString(tokenBytes));
        Cursor c = context.getContentResolver()
                .query(TimeZoneRulesDataContract.Operation.CONTENT_URI,
                        new String[] {
                                TimeZoneRulesDataContract.Operation.COLUMN_TYPE,
                                TimeZoneRulesDataContract.Operation.COLUMN_DISTRO_MAJOR_VERSION,
                                TimeZoneRulesDataContract.Operation.COLUMN_DISTRO_MINOR_VERSION,
                                TimeZoneRulesDataContract.Operation.COLUMN_RULES_VERSION,
                                TimeZoneRulesDataContract.Operation.COLUMN_REVISION
                        },
                        null /* selection */, null /* selectionArgs */, null /* sortOrder */);
        try (Cursor cursor = c) {
            if (cursor == null) {
                Log.e(TAG, "Query returned null");
                return null;
            }
            if (!cursor.moveToFirst()) {
                Log.e(TAG, "Query returned empty results");
                return null;
            }

            try {
                String type = cursor.getString(0);
                DistroFormatVersion distroFormatVersion = null;
                DistroRulesVersion distroRulesVersion = null;
                if (TimeZoneRulesDataContract.Operation.TYPE_INSTALL.equals(type)) {
                    distroFormatVersion = new DistroFormatVersion(cursor.getInt(1),
                            cursor.getInt(2));
                    distroRulesVersion = new DistroRulesVersion(cursor.getString(3),
                            cursor.getInt(4));
                }
                return new DistroOperation(type, distroFormatVersion, distroRulesVersion);
            } catch (Exception e) {
                Log.e(TAG, "Error looking up distro operation / version", e);
                return null;
            }
        }
    }

    private void handleCopyAndInstall(Context context, byte[] checkToken,
            DistroFormatVersion distroFormatVersion, DistroRulesVersion distroRulesVersion) {
        // Decide whether to proceed with the install.
        RulesState rulesState = mRulesManager.getRulesState();
        if (!rulesState.isDistroFormatVersionSupported(distroFormatVersion)
            || rulesState.isBaseVersionNewerThan(distroRulesVersion)) {
            Log.d(TAG, "Candidate distro is not supported or is not better than base version.");
            // Nothing to do.
            handleCheckComplete(checkToken, true /* success */);
            return;
        }

        ParcelFileDescriptor inputFileDescriptor = getDistroParcelFileDescriptor(context);
        if (inputFileDescriptor == null) {
            Log.e(TAG, "No local file created for distro. Halting.");
            return;
        }

        // Copying the ParcelFileDescriptor to a local file proves we can read it before passing it
        // on to the next stage. It also ensures that we have a hermetic copy of the data we know
        // the originating content provider cannot modify unexpectedly. If the next stage wants to
        // "seek" the ParcelFileDescriptor it can do so with fewer processes affected.
        File file = copyDataToLocalFile(context, inputFileDescriptor);
        if (file == null) {
            Log.e(TAG, "Failed to copy distro data to a file.");
            // It's possible this may get better if the problem is related to storage space so we
            // signal success := false so it may be retried.
            boolean success = false;
            handleCheckComplete(checkToken, success);
            return;
        }
        handleInstall(checkToken, file);
    }

    private static ParcelFileDescriptor getDistroParcelFileDescriptor(Context context) {
        ParcelFileDescriptor inputFileDescriptor;
        try {
            inputFileDescriptor = context.getContentResolver().openFileDescriptor(
                    TimeZoneRulesDataContract.Operation.CONTENT_URI, "r");
            if (inputFileDescriptor == null) {
                throw new FileNotFoundException("ContentProvider returned null");
            }
        } catch (FileNotFoundException e) {
            Log.e(TAG, "Unable to open file descriptor"
                    + TimeZoneRulesDataContract.Operation.CONTENT_URI, e);
            return null;
        }
        return inputFileDescriptor;
    }

    private static File copyDataToLocalFile(
            Context context, ParcelFileDescriptor inputFileDescriptor) {

        // Adopt the ParcelFileDescriptor into a try-with-resources so we will close it when we're
        // done regardless of the outcome.
        try (ParcelFileDescriptor pfd = inputFileDescriptor) {
            File localFile;
            try {
                localFile = File.createTempFile("temp", ".zip", context.getFilesDir());
            } catch (IOException e) {
                Log.e(TAG, "Unable to create local storage file", e);
                return null;
            }

            InputStream fis = new FileInputStream(pfd.getFileDescriptor(), false /* isFdOwner */);
            try (FileOutputStream fos = new FileOutputStream(localFile, false /* append */)) {
                Streams.copy(fis, fos);
            } catch (IOException e) {
                Log.e(TAG, "Unable to create asset storage file: " + localFile, e);
                return null;
            }
            return localFile;
        } catch (IOException e) {
            Log.e(TAG, "Unable to close ParcelFileDescriptor", e);
            return null;
        }
    }

    private void handleInstall(final byte[] checkToken, final File localFile) {
        // Create a ParcelFileDescriptor pointing to localFile.
        final ParcelFileDescriptor distroFileDescriptor;
        try {
            distroFileDescriptor =
                    ParcelFileDescriptor.open(localFile, ParcelFileDescriptor.MODE_READ_ONLY);
        } catch (FileNotFoundException e) {
            Log.e(TAG, "Unable to create ParcelFileDescriptor from " + localFile);
            handleCheckComplete(checkToken, false /* success */);
            return;
        } finally {
            // It is safe to delete the File at this point. The ParcelFileDescriptor has an open
            // file descriptor to it if we are successful, or it is not going to be used if we are
            // returning early.
            localFile.delete();
        }

        Callback callback = new Callback() {
            @Override
            public void onFinished(int status) {
                Log.i(TAG, "Finished install: " + status);
            }
        };

        // Adopt the distroFileDescriptor here so the local file descriptor is closed, whatever the
        // outcome.
        try (ParcelFileDescriptor pfd = distroFileDescriptor) {
            String tokenString = Arrays.toString(checkToken);
            EventLogTags.writeTimezoneCheckRequestInstall(tokenString);
            int requestStatus = mRulesManager.requestInstall(pfd, checkToken, callback);
            Log.i(TAG, "requestInstall() called, token=" + tokenString
                    + ", returned " + requestStatus);
        } catch (Exception e) {
            Log.e(TAG, "Error calling requestInstall()", e);
        }
    }

    private void handleUninstall(byte[] checkToken) {
        Callback callback = new Callback() {
            @Override
            public void onFinished(int status) {
                Log.i(TAG, "Finished uninstall: " + status);
            }
        };

        try {
            String tokenString = Arrays.toString(checkToken);
            EventLogTags.writeTimezoneCheckRequestUninstall(tokenString);
            int requestStatus = mRulesManager.requestUninstall(checkToken, callback);
            Log.i(TAG, "requestUninstall() called, token=" + tokenString
                    + ", returned " + requestStatus);
        } catch (Exception e) {
            Log.e(TAG, "Error calling requestUninstall()", e);
        }
    }

    private void handleCheckComplete(final byte[] token, final boolean success) {
        try {
            String tokenString = Arrays.toString(token);
            EventLogTags.writeTimezoneCheckRequestNothing(tokenString, success ? 1 : 0);
            mRulesManager.requestNothing(token, success);
            Log.i(TAG, "requestNothing() called, token=" + tokenString + ", success=" + success);
        } catch (Exception e) {
            Log.e(TAG, "Error calling requestNothing()", e);
        }
    }

    private static class DistroOperation {
        final String mType;
        final DistroFormatVersion mDistroFormatVersion;
        final DistroRulesVersion mDistroRulesVersion;

        DistroOperation(String type, DistroFormatVersion distroFormatVersion,
                DistroRulesVersion distroRulesVersion) {
            mType = type;
            mDistroFormatVersion = distroFormatVersion;
            mDistroRulesVersion = distroRulesVersion;
        }

        @Override
        public String toString() {
            return "DistroOperation{" +
                    "mType='" + mType + '\'' +
                    ", mDistroFormatVersion=" + mDistroFormatVersion +
                    ", mDistroRulesVersion=" + mDistroRulesVersion +
                    '}';
        }
    }
}
