/*
 * 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 android.telephony.mbms;

import android.annotation.SystemApi;
import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.os.Bundle;
import android.telephony.MbmsDownloadSession;
import android.telephony.mbms.vendor.VendorUtils;
import android.util.Log;

import com.android.internal.annotations.VisibleForTesting;

import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.UUID;

/**
 * The {@link BroadcastReceiver} responsible for handling intents sent from the middleware. Apps
 * that wish to download using MBMS APIs should declare this class in their AndroidManifest.xml as
 * follows:
<pre>{@code
<receiver
    android:name="android.telephony.mbms.MbmsDownloadReceiver"
    android:permission="android.permission.SEND_EMBMS_INTENTS"
    android:enabled="true"
    android:exported="true">
</receiver>}</pre>
 */
public class MbmsDownloadReceiver extends BroadcastReceiver {
    /** @hide */
    public static final String DOWNLOAD_TOKEN_SUFFIX = ".download_token";
    /** @hide */
    public static final String MBMS_FILE_PROVIDER_META_DATA_KEY = "mbms-file-provider-authority";

    private static final String EMBMS_INTENT_PERMISSION = "android.permission.SEND_EMBMS_INTENTS";

    /**
     * Indicates that the requested operation completed without error.
     * @hide
     */
    @SystemApi
    public static final int RESULT_OK = 0;

    /**
     * Indicates that the intent sent had an invalid action. This will be the result if
     * {@link Intent#getAction()} returns anything other than
     * {@link VendorUtils#ACTION_DOWNLOAD_RESULT_INTERNAL},
     * {@link VendorUtils#ACTION_FILE_DESCRIPTOR_REQUEST}, or
     * {@link VendorUtils#ACTION_CLEANUP}.
     * This is a fatal result code and no result extras should be expected.
     * @hide
     */
    @SystemApi
    public static final int RESULT_INVALID_ACTION = 1;

    /**
     * Indicates that the intent was missing some required extras.
     * This is a fatal result code and no result extras should be expected.
     * @hide
     */
    @SystemApi
    public static final int RESULT_MALFORMED_INTENT = 2;

    /**
     * Indicates that the supplied value for {@link VendorUtils#EXTRA_TEMP_FILE_ROOT}
     * does not match what the app has stored.
     * This is a fatal result code and no result extras should be expected.
     * @hide
     */
    @SystemApi
    public static final int RESULT_BAD_TEMP_FILE_ROOT = 3;

    /**
     * Indicates that the manager was unable to move the completed download to its final location.
     * This is a fatal result code and no result extras should be expected.
     * @hide
     */
    @SystemApi
    public static final int RESULT_DOWNLOAD_FINALIZATION_ERROR = 4;

    /**
     * Indicates that the manager was unable to generate one or more of the requested file
     * descriptors.
     * This is a non-fatal result code -- some file descriptors may still be generated, but there
     * is no guarantee that they will be the same number as requested.
     * @hide
     */
    @SystemApi
    public static final int RESULT_TEMP_FILE_GENERATION_ERROR = 5;

    /**
     * Indicates that the manager was unable to notify the app of the completed download.
     * This is a fatal result code and no result extras should be expected.
     * @hide
     */
    @SystemApi
    public static final int RESULT_APP_NOTIFICATION_ERROR = 6;


    private static final String LOG_TAG = "MbmsDownloadReceiver";
    private static final String TEMP_FILE_SUFFIX = ".embms.temp";
    private static final String TEMP_FILE_STAGING_LOCATION = "staged_completed_files";

    private static final int MAX_TEMP_FILE_RETRIES = 5;

    private String mFileProviderAuthorityCache = null;
    private String mMiddlewarePackageNameCache = null;

    /** @hide */
    @Override
    public void onReceive(Context context, Intent intent) {
        verifyPermissionIntegrity(context);

        if (!verifyIntentContents(context, intent)) {
            setResultCode(RESULT_MALFORMED_INTENT);
            return;
        }
        if (!Objects.equals(intent.getStringExtra(VendorUtils.EXTRA_TEMP_FILE_ROOT),
                MbmsTempFileProvider.getEmbmsTempFileDir(context).getPath())) {
            setResultCode(RESULT_BAD_TEMP_FILE_ROOT);
            return;
        }

        if (VendorUtils.ACTION_DOWNLOAD_RESULT_INTERNAL.equals(intent.getAction())) {
            moveDownloadedFile(context, intent);
            cleanupPostMove(context, intent);
        } else if (VendorUtils.ACTION_FILE_DESCRIPTOR_REQUEST.equals(intent.getAction())) {
            generateTempFiles(context, intent);
        } else if (VendorUtils.ACTION_CLEANUP.equals(intent.getAction())) {
            cleanupTempFiles(context, intent);
        } else {
            setResultCode(RESULT_INVALID_ACTION);
        }
    }

    private boolean verifyIntentContents(Context context, Intent intent) {
        if (VendorUtils.ACTION_DOWNLOAD_RESULT_INTERNAL.equals(intent.getAction())) {
            if (!intent.hasExtra(MbmsDownloadSession.EXTRA_MBMS_DOWNLOAD_RESULT)) {
                Log.w(LOG_TAG, "Download result did not include a result code. Ignoring.");
                return false;
            }
            if (!intent.hasExtra(MbmsDownloadSession.EXTRA_MBMS_DOWNLOAD_REQUEST)) {
                Log.w(LOG_TAG, "Download result did not include the associated request. Ignoring.");
                return false;
            }
            // We do not need to verify below extras if the result is not success.
            if (MbmsDownloadSession.RESULT_SUCCESSFUL !=
                    intent.getIntExtra(MbmsDownloadSession.EXTRA_MBMS_DOWNLOAD_RESULT,
                    MbmsDownloadSession.RESULT_CANCELLED)) {
                return true;
            }
            if (!intent.hasExtra(VendorUtils.EXTRA_TEMP_FILE_ROOT)) {
                Log.w(LOG_TAG, "Download result did not include the temp file root. Ignoring.");
                return false;
            }
            if (!intent.hasExtra(MbmsDownloadSession.EXTRA_MBMS_FILE_INFO)) {
                Log.w(LOG_TAG, "Download result did not include the associated file info. " +
                        "Ignoring.");
                return false;
            }
            if (!intent.hasExtra(VendorUtils.EXTRA_FINAL_URI)) {
                Log.w(LOG_TAG, "Download result did not include the path to the final " +
                        "temp file. Ignoring.");
                return false;
            }
            DownloadRequest request = intent.getParcelableExtra(
                    MbmsDownloadSession.EXTRA_MBMS_DOWNLOAD_REQUEST);
            String expectedTokenFileName = request.getHash() + DOWNLOAD_TOKEN_SUFFIX;
            File expectedTokenFile = new File(
                    MbmsUtils.getEmbmsTempFileDirForService(context, request.getFileServiceId()),
                    expectedTokenFileName);
            if (!expectedTokenFile.exists()) {
                Log.w(LOG_TAG, "Supplied download request does not match a token that we have. " +
                        "Expected " + expectedTokenFile);
                return false;
            }
        } else if (VendorUtils.ACTION_FILE_DESCRIPTOR_REQUEST.equals(intent.getAction())) {
            if (!intent.hasExtra(VendorUtils.EXTRA_SERVICE_ID)) {
                Log.w(LOG_TAG, "Temp file request did not include the associated service id." +
                        " Ignoring.");
                return false;
            }
            if (!intent.hasExtra(VendorUtils.EXTRA_TEMP_FILE_ROOT)) {
                Log.w(LOG_TAG, "Download result did not include the temp file root. Ignoring.");
                return false;
            }
        } else if (VendorUtils.ACTION_CLEANUP.equals(intent.getAction())) {
            if (!intent.hasExtra(VendorUtils.EXTRA_SERVICE_ID)) {
                Log.w(LOG_TAG, "Cleanup request did not include the associated service id." +
                        " Ignoring.");
                return false;
            }
            if (!intent.hasExtra(VendorUtils.EXTRA_TEMP_FILE_ROOT)) {
                Log.w(LOG_TAG, "Cleanup request did not include the temp file root. Ignoring.");
                return false;
            }
            if (!intent.hasExtra(VendorUtils.EXTRA_TEMP_FILES_IN_USE)) {
                Log.w(LOG_TAG, "Cleanup request did not include the list of temp files in use. " +
                        "Ignoring.");
                return false;
            }
        }
        return true;
    }

    private void moveDownloadedFile(Context context, Intent intent) {
        DownloadRequest request = intent.getParcelableExtra(
                MbmsDownloadSession.EXTRA_MBMS_DOWNLOAD_REQUEST);
        Intent intentForApp = request.getIntentForApp();
        if (intentForApp == null) {
            Log.i(LOG_TAG, "Malformed app notification intent");
            setResultCode(RESULT_APP_NOTIFICATION_ERROR);
            return;
        }

        int result = intent.getIntExtra(MbmsDownloadSession.EXTRA_MBMS_DOWNLOAD_RESULT,
                MbmsDownloadSession.RESULT_CANCELLED);
        intentForApp.putExtra(MbmsDownloadSession.EXTRA_MBMS_DOWNLOAD_RESULT, result);
        intentForApp.putExtra(MbmsDownloadSession.EXTRA_MBMS_DOWNLOAD_REQUEST, request);

        if (result != MbmsDownloadSession.RESULT_SUCCESSFUL) {
            Log.i(LOG_TAG, "Download request indicated a failed download. Aborting.");
            context.sendBroadcast(intentForApp);
            setResultCode(RESULT_OK);
            return;
        }

        Uri finalTempFile = intent.getParcelableExtra(VendorUtils.EXTRA_FINAL_URI);
        if (!verifyTempFilePath(context, request.getFileServiceId(), finalTempFile)) {
            Log.w(LOG_TAG, "Download result specified an invalid temp file " + finalTempFile);
            setResultCode(RESULT_DOWNLOAD_FINALIZATION_ERROR);
            return;
        }

        FileInfo completedFileInfo =
                (FileInfo) intent.getParcelableExtra(MbmsDownloadSession.EXTRA_MBMS_FILE_INFO);
        Path appSpecifiedDestination = FileSystems.getDefault().getPath(
                request.getDestinationUri().getPath());

        Uri finalLocation;
        try {
            String relativeLocation = getFileRelativePath(request.getSourceUri().getPath(),
                    completedFileInfo.getUri().getPath());
            finalLocation = moveToFinalLocation(finalTempFile, appSpecifiedDestination,
                    relativeLocation);
        } catch (IOException e) {
            Log.w(LOG_TAG, "Failed to move temp file to final destination");
            setResultCode(RESULT_DOWNLOAD_FINALIZATION_ERROR);
            return;
        }
        intentForApp.putExtra(MbmsDownloadSession.EXTRA_MBMS_COMPLETED_FILE_URI, finalLocation);
        intentForApp.putExtra(MbmsDownloadSession.EXTRA_MBMS_FILE_INFO, completedFileInfo);

        context.sendBroadcast(intentForApp);
        setResultCode(RESULT_OK);
    }

    private void cleanupPostMove(Context context, Intent intent) {
        DownloadRequest request = intent.getParcelableExtra(
                MbmsDownloadSession.EXTRA_MBMS_DOWNLOAD_REQUEST);
        if (request == null) {
            Log.w(LOG_TAG, "Intent does not include a DownloadRequest. Ignoring.");
            return;
        }

        List<Uri> tempFiles = intent.getParcelableArrayListExtra(VendorUtils.EXTRA_TEMP_LIST);
        if (tempFiles == null) {
            return;
        }

        for (Uri tempFileUri : tempFiles) {
            if (verifyTempFilePath(context, request.getFileServiceId(), tempFileUri)) {
                File tempFile = new File(tempFileUri.getSchemeSpecificPart());
                if (!tempFile.delete()) {
                    Log.w(LOG_TAG, "Failed to delete temp file at " + tempFile.getPath());
                }
            }
        }
    }

    private void generateTempFiles(Context context, Intent intent) {
        String serviceId = intent.getStringExtra(VendorUtils.EXTRA_SERVICE_ID);
        if (serviceId == null) {
            Log.w(LOG_TAG, "Temp file request did not include the associated service id. " +
                    "Ignoring.");
            setResultCode(RESULT_MALFORMED_INTENT);
            return;
        }
        int fdCount = intent.getIntExtra(VendorUtils.EXTRA_FD_COUNT, 0);
        List<Uri> pausedList = intent.getParcelableArrayListExtra(VendorUtils.EXTRA_PAUSED_LIST);

        if (fdCount == 0 && (pausedList == null || pausedList.size() == 0)) {
            Log.i(LOG_TAG, "No temp files actually requested. Ending.");
            setResultCode(RESULT_OK);
            setResultExtras(Bundle.EMPTY);
            return;
        }

        ArrayList<UriPathPair> freshTempFiles =
                generateFreshTempFiles(context, serviceId, fdCount);
        ArrayList<UriPathPair> pausedFiles =
                generateUrisForPausedFiles(context, serviceId, pausedList);

        Bundle result = new Bundle();
        result.putParcelableArrayList(VendorUtils.EXTRA_FREE_URI_LIST, freshTempFiles);
        result.putParcelableArrayList(VendorUtils.EXTRA_PAUSED_URI_LIST, pausedFiles);
        setResultCode(RESULT_OK);
        setResultExtras(result);
    }

    private ArrayList<UriPathPair> generateFreshTempFiles(Context context, String serviceId,
            int freshFdCount) {
        File tempFileDir = MbmsUtils.getEmbmsTempFileDirForService(context, serviceId);
        if (!tempFileDir.exists()) {
            tempFileDir.mkdirs();
        }

        // Name the files with the template "N-UUID", where N is the request ID and UUID is a
        // random uuid.
        ArrayList<UriPathPair> result = new ArrayList<>(freshFdCount);
        for (int i = 0; i < freshFdCount; i++) {
            File tempFile = generateSingleTempFile(tempFileDir);
            if (tempFile == null) {
                setResultCode(RESULT_TEMP_FILE_GENERATION_ERROR);
                Log.w(LOG_TAG, "Failed to generate a temp file. Moving on.");
                continue;
            }
            Uri fileUri = Uri.fromFile(tempFile);
            Uri contentUri = MbmsTempFileProvider.getUriForFile(
                    context, getFileProviderAuthorityCached(context), tempFile);
            context.grantUriPermission(getMiddlewarePackageCached(context), contentUri,
                    Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
            result.add(new UriPathPair(fileUri, contentUri));
        }

        return result;
    }

    private static File generateSingleTempFile(File tempFileDir) {
        int numTries = 0;
        while (numTries < MAX_TEMP_FILE_RETRIES) {
            numTries++;
            String fileName =  UUID.randomUUID() + TEMP_FILE_SUFFIX;
            File tempFile = new File(tempFileDir, fileName);
            try {
                if (tempFile.createNewFile()) {
                    return tempFile.getCanonicalFile();
                }
            } catch (IOException e) {
                continue;
            }
        }
        return null;
    }

    private ArrayList<UriPathPair> generateUrisForPausedFiles(Context context,
            String serviceId, List<Uri> pausedFiles) {
        if (pausedFiles == null) {
            return new ArrayList<>(0);
        }
        ArrayList<UriPathPair> result = new ArrayList<>(pausedFiles.size());

        for (Uri fileUri : pausedFiles) {
            if (!verifyTempFilePath(context, serviceId, fileUri)) {
                Log.w(LOG_TAG, "Supplied file " + fileUri + " is not a valid temp file to resume");
                setResultCode(RESULT_TEMP_FILE_GENERATION_ERROR);
                continue;
            }
            File tempFile = new File(fileUri.getSchemeSpecificPart());
            if (!tempFile.exists()) {
                Log.w(LOG_TAG, "Supplied file " + fileUri + " does not exist.");
                setResultCode(RESULT_TEMP_FILE_GENERATION_ERROR);
                continue;
            }
            Uri contentUri = MbmsTempFileProvider.getUriForFile(
                    context, getFileProviderAuthorityCached(context), tempFile);
            context.grantUriPermission(getMiddlewarePackageCached(context), contentUri,
                    Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);

            result.add(new UriPathPair(fileUri, contentUri));
        }
        return result;
    }

    private void cleanupTempFiles(Context context, Intent intent) {
        String serviceId = intent.getStringExtra(VendorUtils.EXTRA_SERVICE_ID);
        File tempFileDir = MbmsUtils.getEmbmsTempFileDirForService(context, serviceId);
        final List<Uri> filesInUse =
                intent.getParcelableArrayListExtra(VendorUtils.EXTRA_TEMP_FILES_IN_USE);
        File[] filesToDelete = tempFileDir.listFiles(new FileFilter() {
            @Override
            public boolean accept(File file) {
                File canonicalFile;
                try {
                    canonicalFile = file.getCanonicalFile();
                } catch (IOException e) {
                    Log.w(LOG_TAG, "Got IOException canonicalizing " + file + ", not deleting.");
                    return false;
                }
                // Reject all files that don't match what we think a temp file should look like
                // e.g. download tokens
                if (!canonicalFile.getName().endsWith(TEMP_FILE_SUFFIX)) {
                    return false;
                }
                // If any of the files in use match the uri, return false to reject it from the
                // list to delete.
                Uri fileInUseUri = Uri.fromFile(canonicalFile);
                return !filesInUse.contains(fileInUseUri);
            }
        });
        for (File fileToDelete : filesToDelete) {
            fileToDelete.delete();
        }
    }

    /*
     * Moves a tempfile located at fromPath to its final home where the app wants it
     */
    private static Uri moveToFinalLocation(Uri fromPath, Path appSpecifiedPath,
            String relativeLocation) throws IOException {
        if (!ContentResolver.SCHEME_FILE.equals(fromPath.getScheme())) {
            Log.w(LOG_TAG, "Downloaded file location uri " + fromPath +
                    " does not have a file scheme");
            return null;
        }

        Path fromFile = FileSystems.getDefault().getPath(fromPath.getPath());
        Path toFile = appSpecifiedPath.resolve(relativeLocation);

        if (!Files.isDirectory(toFile.getParent())) {
            Files.createDirectories(toFile.getParent());
        }
        Path result = Files.move(fromFile, toFile,
                StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);

        return Uri.fromFile(result.toFile());
    }

    /**
     * @hide
     */
    @VisibleForTesting
    public static String getFileRelativePath(String sourceUriPath, String fileInfoPath) {
        if (sourceUriPath.endsWith("*")) {
            // This is a wildcard path. Strip the last path component and use that as the root of
            // the relative path.
            int lastSlash = sourceUriPath.lastIndexOf('/');
            sourceUriPath = sourceUriPath.substring(0, lastSlash);
        }
        if (!fileInfoPath.startsWith(sourceUriPath)) {
            Log.e(LOG_TAG, "File location specified in FileInfo does not match the source URI."
                    + " source: " + sourceUriPath + " fileinfo path: " + fileInfoPath);
            return null;
        }
        if (fileInfoPath.length() == sourceUriPath.length()) {
            // This is the single-file download case. Return the name of the file so that the
            // receiver puts the file directly into the dest directory.
            return sourceUriPath.substring(sourceUriPath.lastIndexOf('/') + 1);
        }

        String prefixOmittedPath = fileInfoPath.substring(sourceUriPath.length());
        if (prefixOmittedPath.startsWith("/")) {
            prefixOmittedPath = prefixOmittedPath.substring(1);
        }
        return prefixOmittedPath;
    }

    private static boolean verifyTempFilePath(Context context, String serviceId,
            Uri filePath) {
        if (!ContentResolver.SCHEME_FILE.equals(filePath.getScheme())) {
            Log.w(LOG_TAG, "Uri " + filePath + " does not have a file scheme");
            return false;
        }

        String path = filePath.getSchemeSpecificPart();
        File tempFile = new File(path);
        if (!tempFile.exists()) {
            Log.w(LOG_TAG, "File at " + path + " does not exist.");
            return false;
        }

        if (!MbmsUtils.isContainedIn(
                MbmsUtils.getEmbmsTempFileDirForService(context, serviceId), tempFile)) {
            Log.w(LOG_TAG, "File at " + path + " is not contained in the temp file root," +
                    " which is " + MbmsUtils.getEmbmsTempFileDirForService(context, serviceId));
            return false;
        }

        return true;
    }

    private String getFileProviderAuthorityCached(Context context) {
        if (mFileProviderAuthorityCache != null) {
            return mFileProviderAuthorityCache;
        }

        mFileProviderAuthorityCache = getFileProviderAuthority(context);
        return mFileProviderAuthorityCache;
    }

    private static String getFileProviderAuthority(Context context) {
        ApplicationInfo appInfo;
        try {
            appInfo = context.getPackageManager()
                    .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA);
        } catch (PackageManager.NameNotFoundException e) {
            throw new RuntimeException("Package manager couldn't find " + context.getPackageName());
        }
        if (appInfo.metaData == null) {
            throw new RuntimeException("App must declare the file provider authority as metadata " +
                    "in the manifest.");
        }
        String authority = appInfo.metaData.getString(MBMS_FILE_PROVIDER_META_DATA_KEY);
        if (authority == null) {
            throw new RuntimeException("App must declare the file provider authority as metadata " +
                    "in the manifest.");
        }
        return authority;
    }

    private String getMiddlewarePackageCached(Context context) {
        if (mMiddlewarePackageNameCache == null) {
            mMiddlewarePackageNameCache = MbmsUtils.getMiddlewareServiceInfo(context,
                    MbmsDownloadSession.MBMS_DOWNLOAD_SERVICE_ACTION).packageName;
        }
        return mMiddlewarePackageNameCache;
    }

    private void verifyPermissionIntegrity(Context context) {
        PackageManager pm = context.getPackageManager();
        Intent queryIntent = new Intent(context, MbmsDownloadReceiver.class);
        List<ResolveInfo> infos = pm.queryBroadcastReceivers(queryIntent, 0);
        if (infos.size() != 1) {
            throw new IllegalStateException("Non-unique download receiver in your app");
        }
        ActivityInfo selfInfo = infos.get(0).activityInfo;
        if (selfInfo == null) {
            throw new IllegalStateException("Queried ResolveInfo does not contain a receiver");
        }
        if (MbmsUtils.getOverrideServiceName(context,
                MbmsDownloadSession.MBMS_DOWNLOAD_SERVICE_ACTION) != null) {
            // If an override was specified, just make sure that the permission isn't null.
            if (selfInfo.permission == null) {
                throw new IllegalStateException(
                        "MbmsDownloadReceiver must require some permission");
            }
            return;
        }
        if (!Objects.equals(EMBMS_INTENT_PERMISSION, selfInfo.permission)) {
            throw new IllegalStateException("MbmsDownloadReceiver must require the " +
                    "SEND_EMBMS_INTENTS permission.");
        }
    }
}
