/*
 * Copyright (C) 2016 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.packageinstaller.wear;

import android.annotation.TargetApi;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.IntentSender;
import android.content.pm.PackageInstaller;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import android.util.Log;

import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Implementation of package manager installation using modern PackageInstaller api.
 *
 * Heavily copied from Wearsky/Finsky implementation
 */
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public class PackageInstallerImpl {
    private static final String TAG = "PackageInstallerImpl";

    /** Intent actions used for broadcasts from PackageInstaller back to the local receiver */
    private static final String ACTION_INSTALL_COMMIT =
            "com.android.vending.INTENT_PACKAGE_INSTALL_COMMIT";

    private final Context mContext;
    private final PackageInstaller mPackageInstaller;
    private final Map<String, PackageInstaller.SessionInfo> mSessionInfoMap;
    private final Map<String, PackageInstaller.Session> mOpenSessionMap;

    public PackageInstallerImpl(Context context) {
        mContext = context.getApplicationContext();
        mPackageInstaller = mContext.getPackageManager().getPackageInstaller();

        // Capture a map of known sessions
        // This list will be pruned a bit later (stale sessions will be canceled)
        mSessionInfoMap = new HashMap<String, PackageInstaller.SessionInfo>();
        List<PackageInstaller.SessionInfo> mySessions = mPackageInstaller.getMySessions();
        for (int i = 0; i < mySessions.size(); i++) {
            PackageInstaller.SessionInfo sessionInfo = mySessions.get(i);
            String packageName = sessionInfo.getAppPackageName();
            PackageInstaller.SessionInfo oldInfo = mSessionInfoMap.put(packageName, sessionInfo);

            // Checking for old info is strictly for logging purposes
            if (oldInfo != null) {
                Log.w(TAG, "Multiple sessions for " + packageName + " found. Removing " + oldInfo
                        .getSessionId() + " & keeping " + mySessions.get(i).getSessionId());
            }
        }
        mOpenSessionMap = new HashMap<String, PackageInstaller.Session>();
    }

    /**
     * This callback will be made after an installation attempt succeeds or fails.
     */
    public interface InstallListener {
        /**
         * This callback signals that preflight checks have succeeded and installation
         * is beginning.
         */
        void installBeginning();

        /**
         * This callback signals that installation has completed.
         */
        void installSucceeded();

        /**
         * This callback signals that installation has failed.
         */
        void installFailed(int errorCode, String errorDesc);
    }

    /**
     * This is a placeholder implementation that bundles an entire "session" into a single
     * call. This will be replaced by more granular versions that allow longer session lifetimes,
     * download progress tracking, etc.
     *
     * This must not be called on main thread.
     */
    public void install(final String packageName, ParcelFileDescriptor parcelFileDescriptor,
            final InstallListener callback) {
        // 0. Generic try/catch block because I am not really sure what exceptions (other than
        // IOException) might be thrown by PackageInstaller and I want to handle them
        // at least slightly gracefully.
        try {
            // 1. Create or recover a session, and open it
            // Try recovery first
            PackageInstaller.Session session = null;
            PackageInstaller.SessionInfo sessionInfo = mSessionInfoMap.get(packageName);
            if (sessionInfo != null) {
                // See if it's openable, or already held open
                session = getSession(packageName);
            }
            // If open failed, or there was no session, create a new one and open it.
            // If we cannot create or open here, the failure is terminal.
            if (session == null) {
                try {
                    innerCreateSession(packageName);
                } catch (IOException ioe) {
                    Log.e(TAG, "Can't create session for " + packageName + ": " + ioe.getMessage());
                    callback.installFailed(InstallerConstants.ERROR_INSTALL_CREATE_SESSION,
                            "Could not create session");
                    mSessionInfoMap.remove(packageName);
                    return;
                }
                sessionInfo = mSessionInfoMap.get(packageName);
                try {
                    session = mPackageInstaller.openSession(sessionInfo.getSessionId());
                    mOpenSessionMap.put(packageName, session);
                } catch (SecurityException se) {
                    Log.e(TAG, "Can't open session for " + packageName + ": " + se.getMessage());
                    callback.installFailed(InstallerConstants.ERROR_INSTALL_OPEN_SESSION,
                            "Can't open session");
                    mSessionInfoMap.remove(packageName);
                    return;
                }
            }

            // 2. Launch task to handle file operations.
            InstallTask task = new InstallTask( mContext, packageName, parcelFileDescriptor,
                    callback, session,
                    getCommitCallback(packageName, sessionInfo.getSessionId(), callback));
            task.execute();
            if (task.isError()) {
                cancelSession(sessionInfo.getSessionId(), packageName);
            }
        } catch (Exception e) {
            Log.e(TAG, "Unexpected exception while installing: " + packageName + ": "
                    + e.getMessage());
            callback.installFailed(InstallerConstants.ERROR_INSTALL_SESSION_EXCEPTION,
                    "Unexpected exception while installing " + packageName);
        }
    }

    /**
     * Retrieve an existing session. Will open if needed, but does not attempt to create.
     */
    private PackageInstaller.Session getSession(String packageName) {
        // Check for already-open session
        PackageInstaller.Session session = mOpenSessionMap.get(packageName);
        if (session != null) {
            try {
                // Probe the session to ensure that it's still open. This may or may not
                // throw (if non-open), but it may serve as a canary for stale sessions.
                session.getNames();
                return session;
            } catch (IOException ioe) {
                Log.e(TAG, "Stale open session for " + packageName + ": " + ioe.getMessage());
                mOpenSessionMap.remove(packageName);
            } catch (SecurityException se) {
                Log.e(TAG, "Stale open session for " + packageName + ": " + se.getMessage());
                mOpenSessionMap.remove(packageName);
            }
        }
        // Check to see if this is a known session
        PackageInstaller.SessionInfo sessionInfo = mSessionInfoMap.get(packageName);
        if (sessionInfo == null) {
            return null;
        }
        // Try to open it. If we fail here, assume that the SessionInfo was stale.
        try {
            session = mPackageInstaller.openSession(sessionInfo.getSessionId());
        } catch (SecurityException se) {
            Log.w(TAG, "SessionInfo was stale for " + packageName + " - deleting info");
            mSessionInfoMap.remove(packageName);
            return null;
        } catch (IOException ioe) {
            Log.w(TAG, "IOException opening old session for " + ioe.getMessage()
                    + " - deleting info");
            mSessionInfoMap.remove(packageName);
            return null;
        }
        mOpenSessionMap.put(packageName, session);
        return session;
    }

    /** This version throws an IOException when the session cannot be created */
    private void innerCreateSession(String packageName) throws IOException {
        if (mSessionInfoMap.containsKey(packageName)) {
            Log.w(TAG, "Creating session for " + packageName + " when one already exists");
            return;
        }
        PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(
                PackageInstaller.SessionParams.MODE_FULL_INSTALL);
        params.setAppPackageName(packageName);

        // IOException may be thrown at this point
        int sessionId = mPackageInstaller.createSession(params);
        PackageInstaller.SessionInfo sessionInfo = mPackageInstaller.getSessionInfo(sessionId);
        mSessionInfoMap.put(packageName, sessionInfo);
    }

    /**
     * Cancel a session based on its sessionId. Package name is for logging only.
     */
    private void cancelSession(int sessionId, String packageName) {
        // Close if currently held open
        closeSession(packageName);
        // Remove local record
        mSessionInfoMap.remove(packageName);
        try {
            mPackageInstaller.abandonSession(sessionId);
        } catch (SecurityException se) {
            // The session no longer exists, so we can exit quietly.
            return;
        }
    }

    /**
     * Close a session if it happens to be held open.
     */
    private void closeSession(String packageName) {
        PackageInstaller.Session session = mOpenSessionMap.remove(packageName);
        if (session != null) {
            // Unfortunately close() is not idempotent. Try our best to make this safe.
            try {
                session.close();
            } catch (Exception e) {
                Log.w(TAG, "Unexpected error closing session for " + packageName + ": "
                        + e.getMessage());
            }
        }
    }

    /**
     * Creates a commit callback for the package install that's underway. This will be called
     * some time after calling session.commit() (above).
     */
    private IntentSender getCommitCallback(final String packageName, final int sessionId,
            final InstallListener callback) {
        // Create a single-use broadcast receiver
        BroadcastReceiver broadcastReceiver = new BroadcastReceiver() {
            @Override
            public void onReceive(Context context, Intent intent) {
                mContext.unregisterReceiver(this);
                handleCommitCallback(intent, packageName, sessionId, callback);
            }
        };
        // Create a matching intent-filter and register the receiver
        String action = ACTION_INSTALL_COMMIT + "." + packageName;
        IntentFilter intentFilter = new IntentFilter();
        intentFilter.addAction(action);
        mContext.registerReceiver(broadcastReceiver, intentFilter);

        // Create a matching PendingIntent and use it to generate the IntentSender
        Intent broadcastIntent = new Intent(action);
        PendingIntent pendingIntent = PendingIntent.getBroadcast(mContext, packageName.hashCode(),
                broadcastIntent, PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT
                        | PendingIntent.FLAG_MUTABLE);
        return pendingIntent.getIntentSender();
    }

    /**
     * Examine the extras to determine information about the package update/install, decode
     * the result, and call the appropriate callback.
     *
     * @param intent The intent, which the PackageInstaller will have added Extras to
     * @param packageName The package name we created the receiver for
     * @param sessionId The session Id we created the receiver for
     * @param callback The callback to report success/failure to
     */
    private void handleCommitCallback(Intent intent, String packageName, int sessionId,
            InstallListener callback) {
        if (Log.isLoggable(TAG, Log.DEBUG)) {
            Log.d(TAG, "Installation of " + packageName + " finished with extras "
                    + intent.getExtras());
        }
        String statusMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
        int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, Integer.MIN_VALUE);
        if (status == PackageInstaller.STATUS_SUCCESS) {
            cancelSession(sessionId, packageName);
            callback.installSucceeded();
        } else if (status == -1 /*PackageInstaller.STATUS_USER_ACTION_REQUIRED*/) {
            // TODO - use the constant when the correct/final name is in the SDK
            // TODO This is unexpected, so we are treating as failure for now
            cancelSession(sessionId, packageName);
            callback.installFailed(InstallerConstants.ERROR_INSTALL_USER_ACTION_REQUIRED,
                    "Unexpected: user action required");
        } else {
            cancelSession(sessionId, packageName);
            int errorCode = getPackageManagerErrorCode(status);
            Log.e(TAG, "Error " + errorCode + " while installing " + packageName + ": "
                    + statusMessage);
            callback.installFailed(errorCode, null);
        }
    }

    private int getPackageManagerErrorCode(int status) {
        // This is a hack: because PackageInstaller now reports error codes
        // with small positive values, we need to remap them into a space
        // that is more compatible with the existing package manager error codes.
        // See https://sites.google.com/a/google.com/universal-store/documentation
        //       /android-client/download-error-codes
        int errorCode;
        if (status == Integer.MIN_VALUE) {
            errorCode = InstallerConstants.ERROR_INSTALL_MALFORMED_BROADCAST;
        } else {
            errorCode = InstallerConstants.ERROR_PACKAGEINSTALLER_BASE - status;
        }
        return errorCode;
    }
}
