chromium/chrome/android/java/src/org/chromium/chrome/browser/searchwidget/SearchWidgetProvider.java

// Copyright 2017 The Chromium Authors
// 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.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.widget.RemoteViews;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.app.ActivityOptionsCompat;

import org.chromium.base.ContextUtils;
import org.chromium.base.IntentUtils;
import org.chromium.base.Log;
import org.chromium.base.shared_preferences.SharedPreferencesManager;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.firstrun.FirstRunFlowSequencer;
import org.chromium.chrome.browser.locale.LocaleManager;
import org.chromium.chrome.browser.preferences.ChromePreferenceKeys;
import org.chromium.chrome.browser.preferences.ChromeSharedPreferences;
import org.chromium.chrome.browser.ui.searchactivityutils.SearchActivityClient;
import org.chromium.chrome.browser.ui.searchactivityutils.SearchActivityExtras.IntentOrigin;
import org.chromium.chrome.browser.ui.searchactivityutils.SearchActivityExtras.SearchType;
import org.chromium.chrome.browser.ui.searchactivityutils.SearchActivityPreferencesManager;
import org.chromium.chrome.browser.ui.searchactivityutils.SearchActivityPreferencesManager.SearchActivityPreferences;

import java.util.function.Consumer;

/**
 * Widget that lets the user search using their default search engine.
 *
 * <p>Because this is a BroadcastReceiver, it dies immediately after it runs. A new one is created
 * for each new broadcast.
 *
 * <p>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.
 *
 * <p>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 implements Consumer<SearchActivityPreferences> {
        private final Context mContext;
        private final @Nullable 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;
        }

        /** Returns the {@link SharedPreferencesManager} to store prefs. */
        protected SharedPreferencesManager getChromeSharedPreferences() {
            return ChromeSharedPreferences.getInstance();
        }

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

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

        @Override
        public void accept(SearchActivityPreferences prefs) {
            performUpdate(null, prefs);
        }
    }

    public static final String EXTRA_FROM_SEARCH_WIDGET =
            "org.chromium.chrome.browser.searchwidget.FROM_SEARCH_WIDGET";

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

    private static final Object DELEGATE_LOCK = new Object();

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

    public static void initialize() {
        SearchActivityPreferencesManager.addObserver(getDelegate());
    }

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

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static PendingIntent createIntent(Context context, boolean startVoiceSearch) {
        SearchActivityClient client = new SearchActivityClientImpl();
        // Launch the SearchActivity.
        Intent searchIntent =
                client.createIntent(
                        context,
                        IntentOrigin.SEARCH_WIDGET,
                        null,
                        startVoiceSearch ? SearchType.VOICE : SearchType.TEXT);

        searchIntent.putExtra(EXTRA_FROM_SEARCH_WIDGET, true);

        Bundle optionsBundle =
                ActivityOptionsCompat.makeCustomAnimation(context, R.anim.activity_open_enter, 0)
                        .toBundle();
        return PendingIntent.getActivity(
                context,
                0,
                searchIntent,
                PendingIntent.FLAG_UPDATE_CURRENT
                        | IntentUtils.getPendingIntentMutabilityFlag(false),
                optionsBundle);
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static void performUpdate(int[] ids, SearchActivityPreferences prefs) {
        SearchWidgetProviderDelegate delegate = getDelegate();
        if (ids == null) ids = delegate.getAllSearchWidgetIds();
        if (prefs == null) prefs = SearchActivityPreferencesManager.getCurrent();

        for (int id : ids) {
            RemoteViews views =
                    createWidgetViews(
                            delegate.getContext(),
                            id,
                            prefs.searchEngineName,
                            prefs.voiceSearchAvailable);
            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);

        views.setOnClickPendingIntent(R.id.text_container, createIntent(context, false));
        views.setOnClickPendingIntent(R.id.microphone_icon, createIntent(context, true));
        views.setViewVisibility(
                R.id.microphone_icon, isVoiceSearchAvailable ? View.VISIBLE : 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.setCharSequence(R.id.title, "setHint", text);

        return views;
    }

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

        // This metric is committed synchronously because it relates to crashes.
        prefs.writeIntSync(ChromePreferenceKeys.SEARCH_WIDGET_NUM_CONSECUTIVE_CRASHES, newValue);
    }

    @VisibleForTesting
    static int getNumConsecutiveCrashes(SharedPreferencesManager prefs) {
        return prefs.readInt(ChromePreferenceKeys.SEARCH_WIDGET_NUM_CONSECUTIVE_CRASHES);
    }

    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().getChromeSharedPreferences()) + 1;
            updateNumConsecutiveCrashes(numCrashes);

            if (numCrashes < CRASH_LIMIT) {
                // Absorb the crash.
                Log.e(
                        SearchActivity.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() {
        boolean freIsNotNecessary = !FirstRunFlowSequencer.checkIfFirstRunIsNecessary(false, false);
        boolean noNeedToCheckForSearchDialog =
                !LocaleManager.getInstance().needToCheckForSearchEnginePromo();
        return freIsNotNecessary && noNeedToCheckForSearchDialog;
    }

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