// Copyright 2019 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.android_webview.services;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.os.Binder;
import android.os.Build;
import android.os.IBinder;
import android.os.Process;
import android.os.RemoteException;
import androidx.annotation.NonNull;
import org.chromium.android_webview.common.DeveloperModeUtils;
import org.chromium.android_webview.common.Flag;
import org.chromium.android_webview.common.FlagOverrideHelper;
import org.chromium.android_webview.common.ProductionSupportedFlagList;
import org.chromium.android_webview.common.services.IDeveloperUiService;
import org.chromium.android_webview.common.services.ServiceHelper;
import org.chromium.base.CommandLine;
import org.chromium.base.ContextUtils;
import org.chromium.base.IntentUtils;
import org.chromium.base.Log;
import java.util.HashMap;
import java.util.Map;
import javax.annotation.concurrent.GuardedBy;
/**
* A Service to support Developer UI features. This service enables communication between the
* WebView implementation embedded in apps on the system and the Developer UI.
*/
public final class DeveloperUiService extends Service {
private static final String TAG = "WebViewDevTools";
private static final String CHANNEL_ID = "DevUiChannel";
private static final int FLAG_OVERRIDE_NOTIFICATION_ID = 1;
// Keep in sync with MainActivity.java
private static final String FRAGMENT_ID_INTENT_EXTRA = "fragment-id";
private static final String RESET_FLAGS_INTENT_EXTRA = "reset-flags";
private static final int FRAGMENT_ID_HOME = 0;
private static final int FRAGMENT_ID_CRASHES = 1;
private static final int FRAGMENT_ID_FLAGS = 2;
public static final String NOTIFICATION_TITLE = "Experimental WebView features active";
public static final String NOTIFICATION_CONTENT = "Tap to see experimental WebView features.";
public static final String NOTIFICATION_TICKER = "Experimental WebView features active";
private static final Object sLock = new Object();
@GuardedBy("sLock")
private static Map<String, Boolean> sOverriddenFlags = new HashMap<>();
// This is locked to guard reads/writes to the corresponding SharedPreferences object. Make sure
// all edits to that object are synchronized on sLock.
@GuardedBy("sLock")
private static final String SHARED_PREFS_FILE = "webview_devui_flags";
private static final Map<String, String> INITIAL_SWITCHES =
CommandLine.getInstance().getSwitches();
@GuardedBy("sLock")
private static @NonNull Flag[] sFlagList = ProductionSupportedFlagList.sFlagList;
private final IDeveloperUiService.Stub mBinder =
new IDeveloperUiService.Stub() {
@Override
public void setFlagOverrides(Map overriddenFlags) {
if (Binder.getCallingUid() != Process.myUid()) {
throw new SecurityException(
"setFlagOverrides() may only be called by the Developer UI app");
}
synchronized (sLock) {
applyFlagsToCommandLine(sOverriddenFlags, overriddenFlags);
sOverriddenFlags = overriddenFlags;
writeFlagsToStorageAsync(sOverriddenFlags);
if (sOverriddenFlags.isEmpty()) {
disableDeveloperMode();
} else {
enableDeveloperMode();
}
}
}
};
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
final int mode = super.onStartCommand(intent, flags, startId);
// Service is always expected to run in foreground, so mark as such when it is started.
// Subsequent calls will simply replace the foreground service notification.
markAsForegroundService();
return mode;
}
/**
* Static method to fetch the flag overrides. If this returns an empty map, this will
* asynchronously restart the Service to disable developer mode.
*/
public static Map<String, Boolean> getFlagOverrides() {
Map<String, Boolean> flagOverrides;
synchronized (sLock) {
// Create a copy so the caller can do what it wants with the Map without worrying about
// thread safety.
flagOverrides = new HashMap<>(sOverriddenFlags);
}
if (flagOverrides.isEmpty()) {
// If the map is empty, it's probably because the Service has died. Read flags from
// disk to recover.
flagOverrides = readFlagsFromStorageSync();
// Send flags back to the service so it can properly restore developer mode.
startServiceAndSendFlags(flagOverrides);
}
return flagOverrides;
}
private static void startServiceAndSendFlags(final Map<String, Boolean> flags) {
final Context context = ContextUtils.getApplicationContext();
ServiceConnection connection =
new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
try {
IDeveloperUiService.Stub.asInterface(service).setFlagOverrides(flags);
} catch (RemoteException e) {
Log.e(TAG, "Failed to send flag overrides to service", e);
} finally {
// Unbind when we've sent the flags overrides, since we expect we only
// need to do this once.
context.unbindService(this);
}
}
@Override
public void onServiceDisconnected(ComponentName name) {}
};
Intent intent = new Intent(context, DeveloperUiService.class);
if (!ServiceHelper.bindService(context, intent, connection, Context.BIND_AUTO_CREATE)) {
Log.e(TAG, "Failed to bind to Developer UI service");
}
}
@GuardedBy("sLock")
private static boolean isFlagAllowed(String name) {
for (Flag flag : sFlagList) {
if (flag.getName().equals(name)) return true;
}
return false;
}
private static Map<String, Boolean> readFlagsFromStorageSync() {
synchronized (sLock) {
Map<String, Boolean> flags = new HashMap<>();
Map<String, ?> allPreferences =
ContextUtils.getApplicationContext()
.getSharedPreferences(SHARED_PREFS_FILE, Context.MODE_PRIVATE)
.getAll();
for (Map.Entry<String, ?> entry : allPreferences.entrySet()) {
String flagName = entry.getKey();
// Since flags may be persisted by a previous version, we need to filter by the
// current version's sFlagList (in case flags get removed from
// ProductionSupportedFlagList).
if (!isFlagAllowed(flagName)) {
Log.w(TAG, "Toggling '" + flagName + "' is no longer supported");
continue;
}
if (!(entry.getValue() instanceof Boolean)) {
Log.e(TAG, "Expected value '" + entry.getValue() + "' to be type Boolean");
continue;
}
boolean enabled = (Boolean) entry.getValue();
flags.put(flagName, enabled);
}
return flags;
}
}
private static void writeFlagsToStorageAsync(Map<String, Boolean> flags) {
synchronized (sLock) {
SharedPreferences.Editor editor =
ContextUtils.getApplicationContext()
.getSharedPreferences(SHARED_PREFS_FILE, Context.MODE_PRIVATE)
.edit();
editor.clear();
for (Map.Entry<String, Boolean> entry : flags.entrySet()) {
String flagName = entry.getKey();
boolean enabled = entry.getValue();
editor.putBoolean(flagName, enabled);
}
// Ignore errors, since there's no way to recover. Commit changes async to avoid
// blocking the service.
editor.apply();
}
}
@Override
public IBinder onBind(Intent intent) {
return mBinder;
}
private Intent createFlagsFragmentIntent(boolean resetFlags) {
Intent intent = new Intent("com.android.webview.SHOW_DEV_UI");
intent.setClassName(getPackageName(), "org.chromium.android_webview.devui.MainActivity");
intent.putExtra(FRAGMENT_ID_INTENT_EXTRA, FRAGMENT_ID_FLAGS);
if (resetFlags) {
intent.putExtra(RESET_FLAGS_INTENT_EXTRA, resetFlags);
}
return intent;
}
private void registerDefaultNotificationChannel() {
CharSequence name = "WebView DevTools alerts";
// The channel importance should be consistent with the Notification priority on pre-O.
NotificationChannel channel =
new NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_LOW);
channel.enableVibration(false);
channel.enableLights(false);
NotificationManager notificationManager = getSystemService(NotificationManager.class);
notificationManager.createNotificationChannel(channel);
}
private void markAsForegroundService() {
registerDefaultNotificationChannel();
Intent openFlagsIntent = createFlagsFragmentIntent(false);
PendingIntent pendingOpenFlagsIntent =
PendingIntent.getActivity(
this,
0,
openFlagsIntent,
IntentUtils.getPendingIntentMutabilityFlag(false));
// While this service does ultimately manage writing the flag overrides, we would run
// into issues around synchronizing with the flags fragment if it's open because it holds
// onto the state of the flags so we send an intent to reset through there.
Intent resetIntent = createFlagsFragmentIntent(true);
PendingIntent pendingResetExperimentsIntent =
PendingIntent.getActivity(
this, 1, resetIntent, IntentUtils.getPendingIntentMutabilityFlag(false));
Notification.Action resetExperimentsAction =
new Notification.Action.Builder(
org.chromium.android_webview.devui.R.drawable.ic_flag,
"Disable experimental features",
pendingResetExperimentsIntent)
.build();
Notification notification =
new Notification.Builder(this, CHANNEL_ID)
.setContentTitle(NOTIFICATION_TITLE)
.setContentText(NOTIFICATION_CONTENT)
.setSmallIcon(org.chromium.android_webview.devui.R.drawable.ic_flag)
.setContentIntent(pendingOpenFlagsIntent)
.setOngoing(true)
.setVisibility(Notification.VISIBILITY_PUBLIC)
.addAction(resetExperimentsAction)
.setTicker(NOTIFICATION_TICKER)
.build();
try {
startForeground(FLAG_OVERRIDE_NOTIFICATION_ID, notification);
} catch (IllegalStateException e) {
logSuspectedForegroundServiceStartNotAllowedException();
}
}
private void logSuspectedForegroundServiceStartNotAllowedException() {
// Expecting a ForegroundServiceStartNotAllowedException, but that's an S API.
assert Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
: "Unable enable developer mode, this is only expected on Android S";
String msg =
"Unable to create foreground service (client is likely in "
+ "background). Continuing as a background service.";
Log.w(TAG, msg);
}
/**
* Enables developer mode. This includes requesting foreground status, toggling
* {@code DEVELOPER_MODE_STATE_COMPONENT}'s enabled status, posting the notification, etc.
*
* @throws IllegalStateException if we're on Android S+ and we're currently running with
* background status. In this case, {@code mDeveloperModeEnabled} will be {@code false} and
* {@code DEVELOPER_MODE_STATE_COMPONENT} will be unmodified so that we can call try again when
* the next client connects.
*/
private void enableDeveloperMode() {
synchronized (sLock) {
// Mark developer mode as enabled for other apps.
ComponentName developerModeState =
new ComponentName(this, DeveloperModeUtils.DEVELOPER_MODE_STATE_COMPONENT);
getPackageManager()
.setComponentEnabledSetting(
developerModeState,
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP);
// Keep this service alive as long as we're in developer mode.
Intent intent = new Intent(this, DeveloperUiService.class);
try {
startForegroundService(intent);
} catch (IllegalStateException e) {
// Android O doesn't allow bound Services to request foreground status unless the
// app is running in the foreground already or we already started the service with
// Context#startForegroundService.
logSuspectedForegroundServiceStartNotAllowedException();
}
}
}
private void disableDeveloperMode() {
synchronized (sLock) {
ComponentName developerModeState =
new ComponentName(this, DeveloperModeUtils.DEVELOPER_MODE_STATE_COMPONENT);
getPackageManager()
.setComponentEnabledSetting(
developerModeState,
PackageManager.COMPONENT_ENABLED_STATE_DEFAULT,
PackageManager.DONT_KILL_APP);
// Finally, stop the service explicitly. Do this last to make sure we do the other
// necessary cleanup.
stopForeground(STOP_FOREGROUND_REMOVE);
stopSelf();
}
}
/**
* Undoes {@code oldFlags} and applies {@code newFlags}. When undoing {@code oldFlags}, we do
* a best-effort attempt to restore the initial CommandLine state set by the flags file. More
* precisely, we default to whatever state is captured by INITIAL_SWITCHES.
*
* <p><b>Note:</b> {@code newFlags} are not applied atomically.
*/
@GuardedBy("sLock")
private void applyFlagsToCommandLine(
Map<String, Boolean> oldFlags, Map<String, Boolean> newFlags) {
// Best-effort attempt to undo oldFlags back to the initial CommandLine.
for (String flagName : oldFlags.keySet()) {
if (INITIAL_SWITCHES.containsKey(flagName)) {
// If the initial CommandLine had this switch, restore its value.
CommandLine.getInstance()
.appendSwitchWithValue(flagName, INITIAL_SWITCHES.get(flagName));
} else if (CommandLine.getInstance().hasSwitch(flagName)) {
// Otherwise, make sure it's removed from the CommandLine. As an optimization, this
// is only necessary if the current CommandLine has the switch.
CommandLine.getInstance().removeSwitch(flagName);
}
}
// Apply newFlags
FlagOverrideHelper helper = new FlagOverrideHelper(sFlagList);
helper.applyFlagOverrides(newFlags);
}
public static void clearSharedPrefsForTesting(Context context) {
synchronized (sLock) {
context.getSharedPreferences(DeveloperUiService.SHARED_PREFS_FILE, Context.MODE_PRIVATE)
.edit()
.clear()
.apply();
}
}
public static void setFlagListForTesting(@NonNull Flag[] flagList) {
synchronized (sLock) {
sFlagList = flagList;
}
}
}