// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.externalnav;

import android.Manifest.permission;
import android.app.Activity;
import android.content.Context;
import android.content.DialogInterface;
import android.content.DialogInterface.OnCancelListener;
import android.content.DialogInterface.OnClickListener;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.os.Build;
import android.os.StrictMode;
import android.provider.Browser;
import android.provider.Telephony;
import android.support.v7.app.AlertDialog;
import android.text.TextUtils;
import android.webkit.MimeTypeMap;

import org.chromium.base.ApplicationState;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.ContextUtils;
import org.chromium.base.PathUtils;
import org.chromium.base.ThreadUtils;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ChromeActivity;
import org.chromium.chrome.browser.ChromeTabbedActivity2;
import org.chromium.chrome.browser.IntentHandler;
import org.chromium.chrome.browser.UrlConstants;
import org.chromium.chrome.browser.document.ChromeLauncherActivity;
import org.chromium.chrome.browser.externalnav.ExternalNavigationHandler.OverrideUrlLoadingResult;
import org.chromium.chrome.browser.instantapps.AuthenticatedProxyActivity;
import org.chromium.chrome.browser.instantapps.InstantAppsHandler;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.util.IntentUtils;
import org.chromium.chrome.browser.util.UrlUtilities;
import org.chromium.chrome.browser.webapps.WebappActivity;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.NavigationController;
import org.chromium.content_public.browser.NavigationEntry;
import org.chromium.content_public.common.Referrer;
import org.chromium.ui.base.PageTransition;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.base.WindowAndroid.PermissionCallback;
import org.chromium.webapk.lib.client.WebApkValidator;

import java.util.ArrayList;
import java.util.List;

/**
 * The main implementation of the {@link ExternalNavigationDelegate}.
 */
public class ExternalNavigationDelegateImpl implements ExternalNavigationDelegate {
    private static final String PDF_VIEWER = "com.google.android.apps.docs";
    private static final String PDF_MIME = "application/pdf";
    private static final String PDF_SUFFIX = ".pdf";
    private static final String PDF_EXTENSION = "pdf";

    protected final Context mApplicationContext;
    private final Tab mTab;

    public ExternalNavigationDelegateImpl(Tab tab) {
        mTab = tab;
        mApplicationContext = ContextUtils.getApplicationContext();
    }

    /**
     * Get a {@link Context} linked to this delegate with preference to {@link Activity}.
     * The tab this delegate associates with can swap the {@link Activity} it is hosted in and
     * during the swap, there might not be an available {@link Activity}.
     * @return The activity {@link Context} if it can be reached.
     *         Application {@link Context} if not.
     */
    protected final Context getAvailableContext() {
        if (mTab.getWindowAndroid() == null) return mApplicationContext;
        Context activityContext = WindowAndroid.activityFromContext(
                mTab.getWindowAndroid().getContext().get());
        if (activityContext == null) return mApplicationContext;
        return activityContext;
    }

    /**
     * If the intent is for a pdf, resolves intent handlers to find the platform pdf viewer if
     * it is available and force is for the provided |intent| so that the user doesn't need to
     * choose it from Intent picker.
     *
     * @param intent Intent to open.
     */
    public static void forcePdfViewerAsIntentHandlerIfNeeded(Intent intent) {
        if (intent == null || !isPdfIntent(intent)) return;
        resolveIntent(intent, true /* allowSelfOpen (ignored) */);
    }

    /**
     * Retrieve the best activity for the given intent. If a default activity is provided,
     * choose the default one. Otherwise, return the Intent picker if there are more than one
     * capable activities. If the intent is pdf type, return the platform pdf viewer if
     * it is available so user don't need to choose it from Intent picker.
     *
     * Note this function is slow on Android versions less than Lollipop.
     *
     * @param intent Intent to open.
     * @param allowSelfOpen Whether chrome itself is allowed to open the intent.
     * @return true if the intent can be resolved, or false otherwise.
     */
    public static boolean resolveIntent(Intent intent, boolean allowSelfOpen) {
        try {
            boolean activityResolved = false;
            Context context = ContextUtils.getApplicationContext();
            ResolveInfo info = context.getPackageManager().resolveActivity(intent, 0);
            if (info != null) {
                final String packageName = context.getPackageName();
                if (info.match != 0) {
                    // There is a default activity for this intent, use that.
                    if (allowSelfOpen || !packageName.equals(info.activityInfo.packageName)) {
                        activityResolved = true;
                    }
                } else {
                    List<ResolveInfo> handlers = context.getPackageManager().queryIntentActivities(
                            intent, PackageManager.MATCH_DEFAULT_ONLY);
                    if (handlers != null && !handlers.isEmpty()) {
                        activityResolved = true;
                        boolean canSelfOpen = false;
                        boolean hasPdfViewer = false;
                        for (ResolveInfo resolveInfo : handlers) {
                            String pName = resolveInfo.activityInfo.packageName;
                            if (packageName.equals(pName)) {
                                canSelfOpen = true;
                            } else if (PDF_VIEWER.equals(pName)) {
                                if (isPdfIntent(intent)) {
                                    intent.setClassName(pName, resolveInfo.activityInfo.name);
                                    Uri referrer = new Uri.Builder().scheme(
                                            IntentHandler.ANDROID_APP_REFERRER_SCHEME).authority(
                                                    packageName).build();
                                    intent.putExtra(Intent.EXTRA_REFERRER, referrer);
                                    hasPdfViewer = true;
                                    break;
                                }
                            }
                        }
                        if ((canSelfOpen && !allowSelfOpen) && !hasPdfViewer) {
                            activityResolved = false;
                        }
                    }
                }
            }
            return activityResolved;
        } catch (RuntimeException e) {
            IntentUtils.logTransactionTooLargeOrRethrow(e, intent);
        }
        return false;
    }

    private static boolean isPdfIntent(Intent intent) {
        if (intent == null || intent.getData() == null) return false;
        String filename = intent.getData().getLastPathSegment();
        return (filename != null && filename.endsWith(PDF_SUFFIX))
                || PDF_MIME.equals(intent.getType());
    }

    /**
     * Retrieve information about the Activity that will handle the given Intent.
     *
     * Note this function is slow on Android versions less than Lollipop.
     *
     * @param intent Intent to resolve.
     * @return       ResolveInfo of the Activity that will handle the Intent, or null if it failed.
     */
    public static ResolveInfo resolveActivity(Intent intent) {
        // This function is expensive on KK and below and should not be called from main thread.
        assert Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
                || !ThreadUtils.runningOnUiThread();
        try {
            Context context = ContextUtils.getApplicationContext();
            PackageManager pm = context.getPackageManager();
            return pm.resolveActivity(intent, 0);
        } catch (RuntimeException e) {
            IntentUtils.logTransactionTooLargeOrRethrow(e, intent);
        }
        return null;
    }

    /**
     * Determines whether Chrome will be handling the given Intent.
     *
     * Note this function is slow on Android versions less than Lollipop.
     *
     * @param intent            Intent that will be fired.
     * @param matchDefaultOnly  See {@link PackageManager#MATCH_DEFAULT_ONLY}.
     * @return                  True if Chrome will definitely handle the intent, false otherwise.
     */
    public static boolean willChromeHandleIntent(Intent intent, boolean matchDefaultOnly) {
        Context context = ContextUtils.getApplicationContext();
        try {
            // Early-out if the intent targets Chrome.
            if (intent.getComponent() != null
                    && context.getPackageName().equals(intent.getComponent().getPackageName())) {
                return true;
            }

            // Fall back to the more expensive querying of Android when the intent doesn't target
            // Chrome.
            ResolveInfo info = context.getPackageManager().resolveActivity(
                    intent, matchDefaultOnly ? PackageManager.MATCH_DEFAULT_ONLY : 0);
            return info != null && info.activityInfo.packageName.equals(context.getPackageName());
        } catch (RuntimeException e) {
            IntentUtils.logTransactionTooLargeOrRethrow(e, intent);
            return false;
        }
    }

    @Override
    public List<ResolveInfo> queryIntentActivities(Intent intent) {
        // White-list for Samsung. See http://crbug.com/613977 for more context.
        StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads();
        try {
            return mApplicationContext.getPackageManager()
                    .queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER);
        } catch (RuntimeException e) {
            IntentUtils.logTransactionTooLargeOrRethrow(e, intent);
            return null;
        } finally {
            StrictMode.setThreadPolicy(oldPolicy);
        }
    }

    @Override
    public boolean willChromeHandleIntent(Intent intent) {
        return willChromeHandleIntent(intent, false);
    }

    @Override
    public boolean isSpecializedHandlerAvailable(List<ResolveInfo> infos) {
        return countSpecializedHandlers(infos) > 0;
    }

    @Override
    public boolean isWithinCurrentWebappScope(String url) {
        Context context = getAvailableContext();
        if (context instanceof WebappActivity) {
            String scope = ((WebappActivity) context).getWebappScope();
            return url.startsWith(scope);
        }
        return false;
    }

    @Override
    public int countSpecializedHandlers(List<ResolveInfo> infos) {
        return getSpecializedHandlersWithFilter(infos, null).size();
    }

    @VisibleForTesting
    static ArrayList<String> getSpecializedHandlersWithFilter(
            List<ResolveInfo> infos, String filterPackageName) {
        ArrayList<String> result = new ArrayList<>();
        if (infos == null) {
            return result;
        }

        for (ResolveInfo info : infos) {
            IntentFilter filter = info.filter;
            if (filter == null) {
                // Error on the side of classifying ResolveInfo as generic.
                continue;
            }
            if (filter.countDataAuthorities() == 0 && filter.countDataPaths() == 0) {
                // Don't count generic handlers.
                continue;
            }

            if (!TextUtils.isEmpty(filterPackageName)
                    && (info.activityInfo == null
                               || !info.activityInfo.packageName.equals(filterPackageName))) {
                continue;
            }

            if (info.activityInfo != null) {
                if (InstantAppsHandler.getInstance().isInstantAppResolveInfo(info)) {
                    // Don't consider the Instant Apps resolver a specialized application.
                    continue;
                }

                result.add(info.activityInfo.packageName);
            } else {
                result.add("");
            }
        }
        return result;
    }

    /**
     * Check whether the given package is a specialized handler for the given intent
     *
     * @param packageName Package name to check against. Can be null or empty.
     * @param intent The intent to resolve for.
     * @return Whether the given package is a specialized handler for the given intent. If there is
     *         no package name given checks whether there is any specialized handler.
     */
    public static boolean isPackageSpecializedHandler(String packageName, Intent intent) {
        Context context = ContextUtils.getApplicationContext();
        try {
            List<ResolveInfo> handlers = context.getPackageManager().queryIntentActivities(
                    intent, PackageManager.GET_RESOLVED_FILTER);
            return getSpecializedHandlersWithFilter(handlers, packageName).size() > 0;
        } catch (RuntimeException e) {
            IntentUtils.logTransactionTooLargeOrRethrow(e, intent);
        }
        return false;
    }

    @Override
    public String findWebApkPackageName(List<ResolveInfo> infos) {
        return WebApkValidator.findWebApkPackage(mApplicationContext, infos);
    }

    @Override
    public void startActivity(Intent intent, boolean proxy) {
        try {
            forcePdfViewerAsIntentHandlerIfNeeded(intent);
            if (proxy) {
                dispatchAuthenticatedIntent(intent);
            } else {
                Context context = getAvailableContext();
                if (!(context instanceof Activity)) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                context.startActivity(intent);
            }
            recordExternalNavigationDispatched(intent);
        } catch (RuntimeException e) {
            IntentUtils.logTransactionTooLargeOrRethrow(e, intent);
        }
    }

    @Override
    public boolean startActivityIfNeeded(Intent intent, boolean proxy) {
        boolean activityWasLaunched;
        // Only touches disk on Kitkat. See http://crbug.com/617725 for more context.
        StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
        try {
            forcePdfViewerAsIntentHandlerIfNeeded(intent);
            if (proxy) {
                dispatchAuthenticatedIntent(intent);
                activityWasLaunched = true;
            } else {
                Context context = getAvailableContext();
                if (context instanceof Activity) {
                    activityWasLaunched = ((Activity) context).startActivityIfNeeded(intent, -1);
                } else {
                    activityWasLaunched = false;
                }
            }
            if (activityWasLaunched) recordExternalNavigationDispatched(intent);
            return activityWasLaunched;
        } catch (RuntimeException e) {
            IntentUtils.logTransactionTooLargeOrRethrow(e, intent);
            return false;
        } finally {
            StrictMode.setThreadPolicy(oldPolicy);
        }
    }

    private void recordExternalNavigationDispatched(Intent intent) {
        ArrayList<String> specializedHandlers = intent.getStringArrayListExtra(
                IntentHandler.EXTRA_EXTERNAL_NAV_PACKAGES);
        if (specializedHandlers != null && specializedHandlers.size() > 0) {
            RecordUserAction.record("MobileExternalNavigationDispatched");
        }
    }

    @Override
    public void startIncognitoIntent(final Intent intent, final String referrerUrl,
            final String fallbackUrl, final Tab tab, final boolean needsToCloseTab,
            final boolean proxy) {
        Context context = tab.getWindowAndroid().getContext().get();
        if (!(context instanceof Activity)) return;

        Activity activity = (Activity) context;
        new AlertDialog.Builder(activity, R.style.AlertDialogTheme)
                .setTitle(R.string.external_app_leave_incognito_warning_title)
                .setMessage(R.string.external_app_leave_incognito_warning)
                .setPositiveButton(R.string.external_app_leave_incognito_leave,
                        new OnClickListener() {
                            @Override
                            public void onClick(DialogInterface dialog, int which) {
                                startActivity(intent, proxy);
                                if (tab != null && !tab.isClosing() && tab.isInitialized()
                                        && needsToCloseTab) {
                                    closeTab(tab);
                                }
                            }
                        })
                .setNegativeButton(R.string.external_app_leave_incognito_stay,
                        new OnClickListener() {
                            @Override
                            public void onClick(DialogInterface dialog, int which) {
                                loadIntent(intent, referrerUrl, fallbackUrl, tab, needsToCloseTab,
                                        true);
                            }
                        })
                .setOnCancelListener(new OnCancelListener() {
                    @Override
                    public void onCancel(DialogInterface dialog) {
                        loadIntent(intent, referrerUrl, fallbackUrl, tab, needsToCloseTab, true);
                    }
                })
                .show();
    }

    @Override
    public boolean shouldRequestFileAccess(String url, Tab tab) {
        // If the tab is null, then do not attempt to prompt for access.
        if (tab == null) return false;

        // If the url points inside of Chromium's data directory, no permissions are necessary.
        // This is required to prevent permission prompt when uses wants to access offline pages.
        if (url.startsWith(UrlConstants.FILE_URL_PREFIX + PathUtils.getDataDirectory())) {
            return false;
        }

        return !tab.getWindowAndroid().hasPermission(permission.READ_EXTERNAL_STORAGE)
                && tab.getWindowAndroid().canRequestPermission(permission.READ_EXTERNAL_STORAGE);
    }

    @Override
    public void startFileIntent(final Intent intent, final String referrerUrl, final Tab tab,
            final boolean needsToCloseTab) {
        PermissionCallback permissionCallback = new PermissionCallback() {
            @Override
            public void onRequestPermissionsResult(String[] permissions, int[] grantResults) {
                if (grantResults.length > 0
                        && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    loadIntent(intent, referrerUrl, null, tab, needsToCloseTab, tab.isIncognito());
                } else {
                    // TODO(tedchoc): Show an indication to the user that the navigation failed
                    //                instead of silently dropping it on the floor.
                    if (needsToCloseTab) {
                        // If the access was not granted, then close the tab if necessary.
                        closeTab(tab);
                    }
                }
            }
        };
        tab.getWindowAndroid().requestPermissions(
                new String[] {permission.READ_EXTERNAL_STORAGE}, permissionCallback);
    }

    private void loadIntent(Intent intent, String referrerUrl, String fallbackUrl, Tab tab,
            boolean needsToCloseTab, boolean launchIncogntio) {
        boolean needsToStartIntent = false;
        if (tab == null || tab.isClosing() || !tab.isInitialized()) {
            needsToStartIntent = true;
            needsToCloseTab = false;
        } else if (needsToCloseTab) {
            needsToStartIntent = true;
        }

        String url = fallbackUrl != null ? fallbackUrl : intent.getDataString();
        if (!UrlUtilities.isAcceptedScheme(url)) {
            if (needsToCloseTab) closeTab(tab);
            return;
        }

        if (needsToStartIntent) {
            intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
            String packageName = ContextUtils.getApplicationContext().getPackageName();
            intent.putExtra(Browser.EXTRA_APPLICATION_ID, packageName);
            if (launchIncogntio) intent.putExtra(IntentHandler.EXTRA_OPEN_NEW_INCOGNITO_TAB, true);
            intent.addCategory(Intent.CATEGORY_BROWSABLE);
            intent.setClassName(packageName, ChromeLauncherActivity.class.getName());
            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            IntentHandler.addTrustedIntentExtras(intent);
            startActivity(intent, false);

            if (needsToCloseTab) closeTab(tab);
            return;
        }

        LoadUrlParams loadUrlParams = new LoadUrlParams(url, PageTransition.AUTO_TOPLEVEL);
        if (!TextUtils.isEmpty(referrerUrl)) {
            Referrer referrer = new Referrer(referrerUrl, Referrer.REFERRER_POLICY_ALWAYS);
            loadUrlParams.setReferrer(referrer);
        }
        tab.loadUrl(loadUrlParams);
    }

    @Override
    public OverrideUrlLoadingResult clobberCurrentTab(
            String url, String referrerUrl, Tab tab) {
        int transitionType = PageTransition.LINK;
        LoadUrlParams loadUrlParams = new LoadUrlParams(url, transitionType);
        if (!TextUtils.isEmpty(referrerUrl)) {
            Referrer referrer = new Referrer(referrerUrl, Referrer.REFERRER_POLICY_ALWAYS);
            loadUrlParams.setReferrer(referrer);
        }
        if (tab != null) {
            tab.loadUrl(loadUrlParams);
            return OverrideUrlLoadingResult.OVERRIDE_WITH_CLOBBERING_TAB;
        } else {
            assert false : "clobberCurrentTab was called with an empty tab.";
            Uri uri = Uri.parse(url);
            Intent intent = new Intent(Intent.ACTION_VIEW, uri);
            String packageName = ContextUtils.getApplicationContext().getPackageName();
            intent.putExtra(Browser.EXTRA_APPLICATION_ID, packageName);
            intent.addCategory(Intent.CATEGORY_BROWSABLE);
            intent.setPackage(packageName);
            startActivity(intent, false);
            return OverrideUrlLoadingResult.OVERRIDE_WITH_EXTERNAL_INTENT;
        }
    }

    @Override
    public boolean isChromeAppInForeground() {
        return ApplicationStatus.getStateForApplication()
                == ApplicationState.HAS_RUNNING_ACTIVITIES;
    }

    @Override
    public void maybeSetWindowId(Intent intent) {
        Context context = getAvailableContext();
        if (!(context instanceof ChromeTabbedActivity2)) return;
        intent.putExtra(IntentHandler.EXTRA_WINDOW_ID, 2);
    }

    @Override
    public String getDefaultSmsPackageName() {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return null;
        return Telephony.Sms.getDefaultSmsPackage(mApplicationContext);
    }

    private void closeTab(Tab tab) {
        Context context = tab.getWindowAndroid().getContext().get();
        if (context instanceof ChromeActivity) {
            ((ChromeActivity) context).getTabModelSelector().closeTab(tab);
        }
    }

    @Override
    public boolean isPdfDownload(String url) {
        String fileExtension = MimeTypeMap.getFileExtensionFromUrl(url);
        if (TextUtils.isEmpty(fileExtension)) return false;

        return PDF_EXTENSION.equals(fileExtension);
    }

    @Override
    public void maybeRecordAppHandlersInIntent(Intent intent, List<ResolveInfo> infos) {
        intent.putExtra(IntentHandler.EXTRA_EXTERNAL_NAV_PACKAGES,
                getSpecializedHandlersWithFilter(infos, null));
    }

    @Override
    public boolean isSerpReferrer(Tab tab) {
        // TODO (thildebr): Investigate whether or not we can use getLastCommittedUrl() instead of
        // the NavigationController.
        if (tab == null || tab.getWebContents() == null) return false;

        NavigationController nController = tab.getWebContents().getNavigationController();
        int index = nController.getLastCommittedEntryIndex();
        if (index == -1) return false;

        NavigationEntry entry = nController.getEntryAtIndex(index);
        if (entry == null) return false;

        return UrlUtilities.nativeIsGoogleSearchUrl(entry.getUrl());
    }

    @Override
    public boolean maybeLaunchInstantApp(Tab tab, String url, String referrerUrl,
            boolean isIncomingRedirect) {
        if (tab == null || tab.getWebContents() == null) return false;

        InstantAppsHandler handler = InstantAppsHandler.getInstance();
        Intent intent = tab.getTabRedirectHandler() != null
                ? tab.getTabRedirectHandler().getInitialIntent() : null;
        // TODO(mariakhomenko): consider also handling NDEF_DISCOVER action redirects.
        if (isIncomingRedirect && intent != null && Intent.ACTION_VIEW.equals(intent.getAction())) {
            // Set the URL the redirect was resolved to for checking the existence of the
            // instant app inside handleIncomingIntent().
            Intent resolvedIntent = new Intent(intent);
            resolvedIntent.setData(Uri.parse(url));
            return handler.handleIncomingIntent(getAvailableContext(), resolvedIntent,
                    ChromeLauncherActivity.isCustomTabIntent(resolvedIntent), true);
        } else if (!isIncomingRedirect) {
            // Check if the navigation is coming from SERP and skip instant app handling.
            if (isSerpReferrer(tab)) return false;
            return handler.handleNavigation(getAvailableContext(), url,
                    TextUtils.isEmpty(referrerUrl) ? null : Uri.parse(referrerUrl), tab);
        }
        return false;
    }

    @Override
    public String getPreviousUrl() {
        if (mTab == null || mTab.getWebContents() == null) return null;
        return mTab.getWebContents().getLastCommittedUrl();
    }

    /**
     * Dispatches the intent through a proxy activity, so that startActivityForResult can be used
     * and the intent recipient can verify the caller.
     * @param intent The bare intent we were going to send.
     */
    protected void dispatchAuthenticatedIntent(Intent intent) {
        Intent proxyIntent = new Intent(Intent.ACTION_MAIN);
        proxyIntent.setClass(getAvailableContext(), AuthenticatedProxyActivity.class);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        proxyIntent.putExtra(AuthenticatedProxyActivity.AUTHENTICATED_INTENT_EXTRA, intent);
        getAvailableContext().startActivity(proxyIntent);
    }
}