// Copyright 2017 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.searchwidget;

import android.annotation.SuppressLint;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Bundle;
import android.os.StrictMode;
import android.support.v4.app.ActivityOptionsCompat;
import android.text.TextUtils;
import android.view.View;
import android.widget.RemoteViews;

import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ThreadUtils;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.library_loader.LibraryLoader;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.IntentHandler;
import org.chromium.chrome.browser.firstrun.FirstRunFlowSequencer;
import org.chromium.chrome.browser.locale.LocaleManager;
import org.chromium.chrome.browser.omnibox.LocationBarLayout;
import org.chromium.chrome.browser.search_engines.TemplateUrlService;
import org.chromium.chrome.browser.search_engines.TemplateUrlService.LoadListener;
import org.chromium.chrome.browser.search_engines.TemplateUrlService.TemplateUrlServiceObserver;
import org.chromium.chrome.browser.util.IntentUtils;

/**
 * Widget that lets the user search using their default search engine.
 *
 * Because this is a BroadcastReceiver, it dies immediately after it runs.  A new one is created
 * for each new broadcast.
 *
 * This class avoids loading the native library because it can be triggered at regular intervals by
 * Android when it tells widgets that they need updates.
 *
 * Methods on instances of this class called directly by Android (when a broadcast is received e.g.)
 * catch all Exceptions up to some number of times before letting them go through to allow us to get
 * a crash stack.  This is done to prevent Android from labeling the whole process as "bad" and
 * blocking taps on the widget.  See http://crbug.com/712061.
 */
public class SearchWidgetProvider extends AppWidgetProvider {
    /** Wraps up all things that a {@link SearchWidgetProvider} can request things from. */
    static class SearchWidgetProviderDelegate {
        private final Context mContext;
        private final AppWidgetManager mManager;

        public SearchWidgetProviderDelegate(Context context) {
            mContext = context == null ? ContextUtils.getApplicationContext() : context;
            mManager = AppWidgetManager.getInstance(mContext);
        }

        /** Returns the Context to pull resources from. */
        protected Context getContext() {
            return mContext;
        }

        /** See {@link ContextUtils#getAppSharedPreferences}. */
        protected SharedPreferences getSharedPreferences() {
            return ContextUtils.getAppSharedPreferences();
        }

        /** Returns IDs for all search widgets that exist. */
        protected int[] getAllSearchWidgetIds() {
            return mManager.getAppWidgetIds(
                    new ComponentName(getContext(), SearchWidgetProvider.class.getName()));
        }

        /** See {@link AppWidgetManager#updateAppWidget}. */
        protected void updateAppWidget(int id, RemoteViews views) {
            mManager.updateAppWidget(id, views);
        }
    }

    /** Monitors the TemplateUrlService for changes, updating the widget when necessary. */
    private static final class SearchWidgetTemplateUrlServiceObserver
            implements LoadListener, TemplateUrlServiceObserver {
        @Override
        public void onTemplateUrlServiceLoaded() {
            TemplateUrlService.getInstance().unregisterLoadListener(this);
            updateCachedEngineName();
        }

        @Override
        public void onTemplateURLServiceChanged() {
            updateCachedEngineName();
        }

        private void updateCachedEngineName() {
            SearchWidgetProvider.updateCachedEngineName();
        }
    }

    static final String ACTION_START_TEXT_QUERY =
            "org.chromium.chrome.browser.searchwidget.START_TEXT_QUERY";
    static final String ACTION_START_VOICE_QUERY =
            "org.chromium.chrome.browser.searchwidget.START_VOICE_QUERY";
    static final String ACTION_UPDATE_ALL_WIDGETS =
            "org.chromium.chrome.browser.searchwidget.UPDATE_ALL_WIDGETS";

    static final String EXTRA_START_VOICE_SEARCH =
            "org.chromium.chrome.browser.searchwidget.START_VOICE_SEARCH";

    private static final String PREF_IS_VOICE_SEARCH_AVAILABLE =
            "org.chromium.chrome.browser.searchwidget.IS_VOICE_SEARCH_AVAILABLE";
    private static final String PREF_NUM_CONSECUTIVE_CRASHES =
            "org.chromium.chrome.browser.searchwidget.NUM_CONSECUTIVE_CRASHES";
    static final String PREF_SEARCH_ENGINE_SHORTNAME =
            "org.chromium.chrome.browser.searchwidget.SEARCH_ENGINE_SHORTNAME";

    /** Number of consecutive crashes this widget will absorb before giving up. */
    private static final int CRASH_LIMIT = 3;

    private static final String TAG = "searchwidget";
    private static final Object DELEGATE_LOCK = new Object();
    private static final Object OBSERVER_LOCK = new Object();

    /** The default search engine's root URL. */
    private static String sDefaultSearchEngineUrl;

    @SuppressLint("StaticFieldLeak")
    private static SearchWidgetTemplateUrlServiceObserver sObserver;
    @SuppressLint("StaticFieldLeak")
    private static SearchWidgetProviderDelegate sDelegate;

    /**
     * Creates the singleton instance of the observer that will monitor for search engine changes.
     * The native library and the browser process must have been fully loaded before calling this.
     */
    public static void initialize() {
        ThreadUtils.assertOnUiThread();
        assert LibraryLoader.isInitialized();

        // Set up an observer to monitor for changes.
        synchronized (OBSERVER_LOCK) {
            if (sObserver != null) return;
            sObserver = new SearchWidgetTemplateUrlServiceObserver();

            TemplateUrlService service = TemplateUrlService.getInstance();
            service.registerLoadListener(sObserver);
            service.addObserver(sObserver);
            if (!service.isLoaded()) service.load();
        }
        int[] ids = getDelegate().getAllSearchWidgetIds();
        LocaleManager.getInstance().recordLocaleBasedSearchWidgetMetrics(
                ids != null && ids.length > 0);
    }

    /** Nukes all cached information and forces all widgets to start with a blank slate. */
    public static void reset() {
        SharedPreferences.Editor editor = getDelegate().getSharedPreferences().edit();
        editor.remove(PREF_IS_VOICE_SEARCH_AVAILABLE);
        editor.remove(PREF_SEARCH_ENGINE_SHORTNAME);
        editor.apply();

        performUpdate(null);
    }

    @Override
    public void onReceive(final Context context, final Intent intent) {
        run(new Runnable() {
            @Override
            public void run() {
                if (IntentHandler.isIntentChromeOrFirstParty(intent)) {
                    handleAction(intent);
                } else {
                    SearchWidgetProvider.super.onReceive(context, intent);
                }
            }
        });
    }

    @Override
    public void onUpdate(final Context context, final AppWidgetManager manager, final int[] ids) {
        run(new Runnable() {
            @Override
            public void run() {
                performUpdate(ids);
            }
        });
    }

    /** Handles the intent actions to the widget. */
    @VisibleForTesting
    static void handleAction(Intent intent) {
        String action = intent.getAction();
        if (ACTION_START_TEXT_QUERY.equals(action)) {
            startSearchActivity(intent, false);
        } else if (ACTION_START_VOICE_QUERY.equals(action)) {
            startSearchActivity(intent, true);
        } else if (ACTION_UPDATE_ALL_WIDGETS.equals(action)) {
            performUpdate(null);
        } else {
            assert false;
        }
    }

    @VisibleForTesting
    static void startSearchActivity(Intent intent, boolean startVoiceSearch) {
        Log.d(TAG, "Launching SearchActivity: VOICE=" + startVoiceSearch);
        Context context = getDelegate().getContext();

        // Abort if the user needs to go through First Run.
        if (FirstRunFlowSequencer.launch(context, intent, true)) return;

        // Launch the SearchActivity.
        Intent searchIntent = new Intent();
        searchIntent.setClass(context, SearchActivity.class);
        searchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        searchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
        searchIntent.putExtra(EXTRA_START_VOICE_SEARCH, startVoiceSearch);

        Bundle optionsBundle =
                ActivityOptionsCompat.makeCustomAnimation(context, R.anim.activity_open_enter, 0)
                        .toBundle();
        IntentUtils.safeStartActivity(context, searchIntent, optionsBundle);
    }

    private static void performUpdate(int[] ids) {
        SearchWidgetProviderDelegate delegate = getDelegate();

        if (ids == null) ids = delegate.getAllSearchWidgetIds();
        if (ids.length == 0) return;

        SharedPreferences prefs = delegate.getSharedPreferences();
        boolean isVoiceSearchAvailable = getCachedVoiceSearchAvailability(prefs);
        String engineName = getCachedEngineName(prefs);

        for (int id : ids) {
            RemoteViews views = createWidgetViews(
                    delegate.getContext(), id, engineName, isVoiceSearchAvailable);
            delegate.updateAppWidget(id, views);
        }
    }

    private static RemoteViews createWidgetViews(
            Context context, int id, String engineName, boolean isVoiceSearchAvailable) {
        RemoteViews views =
                new RemoteViews(context.getPackageName(), R.layout.search_widget_template);

        // Clicking on the widget fires an Intent back at this BroadcastReceiver, allowing control
        // over how the Activity is animated when it starts up.
        Intent textIntent = createStartQueryIntent(context, ACTION_START_TEXT_QUERY, id);
        views.setOnClickPendingIntent(R.id.text_container,
                PendingIntent.getBroadcast(
                        context, 0, textIntent, PendingIntent.FLAG_UPDATE_CURRENT));

        // If voice search is available, clicking on the microphone triggers a voice query.
        if (isVoiceSearchAvailable) {
            Intent voiceIntent = createStartQueryIntent(context, ACTION_START_VOICE_QUERY, id);
            views.setOnClickPendingIntent(R.id.microphone_icon,
                    PendingIntent.getBroadcast(
                            context, 0, voiceIntent, PendingIntent.FLAG_UPDATE_CURRENT));
            views.setViewVisibility(R.id.microphone_icon, View.VISIBLE);
        } else {
            views.setViewVisibility(R.id.microphone_icon, View.GONE);
        }

        // Update what string is displayed by the widget.
        String text = TextUtils.isEmpty(engineName) || !shouldShowFullString()
                ? context.getString(R.string.search_widget_default)
                : context.getString(R.string.search_with_product, engineName);
        views.setTextViewText(R.id.title, text);

        return views;
    }

    /** Creates a trusted Intent that lets the user begin performing queries. */
    private static Intent createStartQueryIntent(Context context, String action, int widgetId) {
        Intent intent = new Intent(action, Uri.parse(String.valueOf(widgetId)));
        intent.setClass(context, SearchWidgetProvider.class);
        IntentHandler.addTrustedIntentExtras(intent);
        return intent;
    }

    /** Caches whether or not a voice search is possible. */
    static void updateCachedVoiceSearchAvailability(boolean isVoiceSearchAvailable) {
        SharedPreferences prefs = getDelegate().getSharedPreferences();
        if (getCachedVoiceSearchAvailability(prefs) != isVoiceSearchAvailable) {
            prefs.edit().putBoolean(PREF_IS_VOICE_SEARCH_AVAILABLE, isVoiceSearchAvailable).apply();
            performUpdate(null);
        }
    }

    /** Attempts to update the cached search engine name. */
    public static void updateCachedEngineName() {
        ThreadUtils.assertOnUiThread();
        if (!LibraryLoader.isInitialized()) return;

        // Getting an instance of the TemplateUrlService requires that the native library be
        // loaded, but the TemplateUrlService also itself needs to be initialized.
        TemplateUrlService service = TemplateUrlService.getInstance();
        if (!service.isLoaded()) return;

        // Update the URL that we show for zero-suggest.
        String searchEngineUrl = service.getSearchEngineUrlFromTemplateUrl(
                service.getDefaultSearchEngineTemplateUrl().getKeyword());
        sDefaultSearchEngineUrl =
                LocationBarLayout.splitPathFromUrlDisplayText(searchEngineUrl).first;

        updateCachedEngineName(service.getDefaultSearchEngineTemplateUrl().getShortName());
    }

    /**
     * Updates the name of the user's default search engine that is cached in SharedPreferences.
     * Caching it in SharedPreferences prevents us from having to load the native library and the
     * TemplateUrlService whenever the widget is updated.
     */
    static void updateCachedEngineName(String engineName) {
        SharedPreferences prefs = getDelegate().getSharedPreferences();

        if (!shouldShowFullString()) engineName = null;

        if (!TextUtils.equals(getCachedEngineName(prefs), engineName)) {
            prefs.edit().putString(PREF_SEARCH_ENGINE_SHORTNAME, engineName).apply();
            performUpdate(null);
        }
    }

    /** Updates the number of consecutive crashes this widget has absorbed. */
    @SuppressLint({"ApplySharedPref", "CommitPrefEdits"})
    static void updateNumConsecutiveCrashes(int newValue) {
        SharedPreferences prefs = getDelegate().getSharedPreferences();
        if (getNumConsecutiveCrashes(prefs) == newValue) return;

        SharedPreferences.Editor editor = prefs.edit();
        if (newValue == 0) {
            editor.remove(PREF_NUM_CONSECUTIVE_CRASHES);
        } else {
            editor.putInt(PREF_NUM_CONSECUTIVE_CRASHES, newValue);
        }

        // This metric is committed synchronously because it relates to crashes.
        StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
        try {
            editor.commit();
        } finally {
            StrictMode.setThreadPolicy(oldPolicy);
        }
    }

    private static boolean getCachedVoiceSearchAvailability(SharedPreferences prefs) {
        return prefs.getBoolean(PREF_IS_VOICE_SEARCH_AVAILABLE, true);
    }

    private static String getCachedEngineName(SharedPreferences prefs) {
        return prefs.getString(PREF_SEARCH_ENGINE_SHORTNAME, null);
    }

    @VisibleForTesting
    static int getNumConsecutiveCrashes(SharedPreferences prefs) {
        return prefs.getInt(PREF_NUM_CONSECUTIVE_CRASHES, 0);
    }

    private static SearchWidgetProviderDelegate getDelegate() {
        synchronized (DELEGATE_LOCK) {
            if (sDelegate == null) sDelegate = new SearchWidgetProviderDelegate(null);
        }
        return sDelegate;
    }

    @VisibleForTesting
    static void run(Runnable runnable) {
        try {
            runnable.run();
            updateNumConsecutiveCrashes(0);
        } catch (Exception e) {
            int numCrashes = getNumConsecutiveCrashes(getDelegate().getSharedPreferences()) + 1;
            updateNumConsecutiveCrashes(numCrashes);

            if (numCrashes < CRASH_LIMIT) {
                // Absorb the crash.
                Log.e(TAG, "Absorbing exception caught when attempting to launch widget.", e);
            } else {
                // Too many crashes have happened consecutively.  Let Android handle it.
                throw e;
            }
        }
    }

    static boolean shouldShowFullString() {
        Intent freIntent = FirstRunFlowSequencer.checkIfFirstRunIsNecessary(
                getDelegate().getContext(), null, false);
        return freIntent == null;
    }

    /** Sets an {@link SearchWidgetProviderDelegate} to interact with. */
    @VisibleForTesting
    static void setActivityDelegateForTest(SearchWidgetProviderDelegate delegate) {
        assert sDelegate == null;
        sDelegate = delegate;
    }

    /** See {@link #sDefaultSearchEngineUrl}. */
    static String getDefaultSearchEngineUrl() {
        // TODO(yusufo): Get rid of this.
        return sDefaultSearchEngineUrl;
    }
}