diff --git a/res/xml/location_settings.xml b/res/xml/location_settings.xml
index 267fce98114..c86df68b2fc 100644
--- a/res/xml/location_settings.xml
+++ b/res/xml/location_settings.xml
@@ -49,5 +49,9 @@
+ android:title="@string/location_category_location_services"/>
+
+
diff --git a/src/com/android/settings/location/LocationFooterPreferenceController.java b/src/com/android/settings/location/LocationFooterPreferenceController.java
new file mode 100644
index 00000000000..f15d43748de
--- /dev/null
+++ b/src/com/android/settings/location/LocationFooterPreferenceController.java
@@ -0,0 +1,223 @@
+/*
+ * Copyright (C) 2018 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 com.android.settings.location;
+
+import android.content.ComponentName;
+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.PackageManager.NameNotFoundException;
+import android.content.pm.ResolveInfo;
+import android.location.LocationManager;
+import android.support.annotation.VisibleForTesting;
+import android.support.v7.preference.Preference;
+import android.support.v7.preference.PreferenceCategory;
+import android.util.Log;
+import com.android.settingslib.core.lifecycle.Lifecycle;
+import com.android.settingslib.core.lifecycle.LifecycleObserver;
+import com.android.settingslib.core.lifecycle.events.OnPause;
+import com.android.settingslib.widget.FooterPreference;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Preference controller for location footer preference category
+ */
+public class LocationFooterPreferenceController extends LocationBasePreferenceController
+ implements LifecycleObserver, OnPause {
+ private static final String TAG = "LocationFooter";
+ private static final String KEY_LOCATION_FOOTER = "location_footer";
+ private static final Intent INJECT_INTENT =
+ new Intent(LocationManager.SETTINGS_FOOTER_DISPLAYED_ACTION);
+ private final Context mContext;
+ private final PackageManager mPackageManager;
+ private Collection mFooterInjectors;
+
+ public LocationFooterPreferenceController(Context context, Lifecycle lifecycle) {
+ super(context, lifecycle);
+ mContext = context;
+ mPackageManager = mContext.getPackageManager();
+ mFooterInjectors = new ArrayList<>();
+ if (lifecycle != null) {
+ lifecycle.addObserver(this);
+ }
+ }
+
+ @Override
+ public String getPreferenceKey() {
+ return KEY_LOCATION_FOOTER;
+ }
+
+ /**
+ * Insert footer preferences. Send a {@link LocationManager#SETTINGS_FOOTER_DISPLAYED_ACTION}
+ * broadcast to receivers who have injected a footer
+ */
+ @Override
+ public void updateState(Preference preference) {
+ PreferenceCategory category = (PreferenceCategory) preference;
+ category.removeAll();
+ mFooterInjectors.clear();
+ Collection footerData = getFooterData();
+ for (FooterData data : footerData) {
+ // Generate a footer preference with the given text
+ FooterPreference footerPreference = new FooterPreference(preference.getContext());
+ String footerString;
+ try {
+ footerString =
+ mPackageManager
+ .getResourcesForApplication(data.applicationInfo)
+ .getString(data.footerStringRes);
+ } catch (NameNotFoundException exception) {
+ if (Log.isLoggable(TAG, Log.WARN)) {
+ Log.w(
+ TAG,
+ "Resources not found for application "
+ + data.applicationInfo.packageName);
+ }
+ continue;
+ }
+ footerPreference.setTitle(footerString);
+ // Inject the footer
+ category.addPreference(footerPreference);
+ // Send broadcast to the injector announcing a footer has been injected
+ sendBroadcastFooterDisplayed(data.componentName);
+ mFooterInjectors.add(data.componentName);
+ }
+ }
+
+ /**
+ * Do nothing on location mode changes.
+ */
+ @Override
+ public void onLocationModeChanged(int mode, boolean restricted) {}
+
+ /**
+ * Location footer preference group should be displayed if there is at least one footer to
+ * inject.
+ */
+ @Override
+ public boolean isAvailable() {
+ return !getFooterData().isEmpty();
+ }
+
+ /**
+ * Send a {@link LocationManager#SETTINGS_FOOTER_REMOVED_ACTION} broadcast to footer injectors
+ * when LocationFragment is on pause
+ */
+ @Override
+ public void onPause() {
+ // Send broadcast to the footer injectors. Notify them the footer is not visible.
+ for (ComponentName componentName : mFooterInjectors) {
+ final Intent intent = new Intent(LocationManager.SETTINGS_FOOTER_REMOVED_ACTION);
+ intent.setComponent(componentName);
+ mContext.sendBroadcast(intent);
+ }
+ }
+
+ /**
+ * Send a {@link LocationManager#SETTINGS_FOOTER_DISPLAYED_ACTION} broadcast to a footer
+ * injector.
+ */
+ @VisibleForTesting
+ void sendBroadcastFooterDisplayed(ComponentName componentName) {
+ Intent intent = new Intent(LocationManager.SETTINGS_FOOTER_DISPLAYED_ACTION);
+ intent.setComponent(componentName);
+ mContext.sendBroadcast(intent);
+ }
+
+ /**
+ * Return a list of strings with text provided by ACTION_INJECT_FOOTER broadcast receivers.
+ */
+ private Collection getFooterData() {
+ // Fetch footer text from system apps
+ final List resolveInfos =
+ mPackageManager.queryBroadcastReceivers(
+ INJECT_INTENT, PackageManager.GET_META_DATA);
+ if (resolveInfos == null) {
+ if (Log.isLoggable(TAG, Log.ERROR)) {
+ Log.e(TAG, "Unable to resolve intent " + INJECT_INTENT);
+ return Collections.emptyList();
+ }
+ } else if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG, "Found broadcast receivers: " + resolveInfos);
+ }
+
+ final Collection footerDataList = new ArrayList<>(resolveInfos.size());
+ for (ResolveInfo resolveInfo : resolveInfos) {
+ final ActivityInfo activityInfo = resolveInfo.activityInfo;
+ final ApplicationInfo appInfo = activityInfo.applicationInfo;
+
+ // If a non-system app tries to inject footer, ignore it
+ if ((appInfo.flags & ApplicationInfo.FLAG_SYSTEM) == 0) {
+ if (Log.isLoggable(TAG, Log.WARN)) {
+ Log.w(TAG, "Ignoring attempt to inject footer from app not in system image: "
+ + resolveInfo);
+ continue;
+ }
+ }
+
+ // Get the footer text resource id from broadcast receiver's metadata
+ if (activityInfo.metaData == null) {
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG, "No METADATA in broadcast receiver " + activityInfo.name);
+ continue;
+ }
+ }
+
+ final int footerTextRes =
+ activityInfo.metaData.getInt(LocationManager.METADATA_SETTINGS_FOOTER_STRING);
+ if (footerTextRes == 0) {
+ if (Log.isLoggable(TAG, Log.WARN)) {
+ Log.w(
+ TAG,
+ "No mapping of integer exists for "
+ + LocationManager.METADATA_SETTINGS_FOOTER_STRING);
+ }
+ continue;
+ }
+ footerDataList.add(
+ new FooterData(
+ footerTextRes,
+ appInfo,
+ new ComponentName(activityInfo.packageName, activityInfo.name)));
+ }
+ return footerDataList;
+ }
+
+ /**
+ * Contains information related to a footer.
+ */
+ private static class FooterData {
+
+ // The string resource of the footer
+ final int footerStringRes;
+
+ // Application info of receiver injecting this footer
+ final ApplicationInfo applicationInfo;
+
+ // The component that injected the footer. It must be a receiver of broadcast
+ // LocationManager.SETTINGS_FOOTER_DISPLAYED_ACTION
+ final ComponentName componentName;
+
+ FooterData(int footerRes, ApplicationInfo appInfo, ComponentName componentName) {
+ this.footerStringRes = footerRes;
+ this.applicationInfo = appInfo;
+ this.componentName = componentName;
+ }
+ }
+}
diff --git a/src/com/android/settings/location/LocationSettings.java b/src/com/android/settings/location/LocationSettings.java
index 3cc5b847f15..510c1625a58 100644
--- a/src/com/android/settings/location/LocationSettings.java
+++ b/src/com/android/settings/location/LocationSettings.java
@@ -131,9 +131,10 @@ public class LocationSettings extends DashboardFragment {
controllers.add(new LocationForWorkPreferenceController(context, lifecycle));
controllers.add(
new RecentLocationRequestPreferenceController(context, fragment, lifecycle));
+ controllers.add(new LocationScanningPreferenceController(context));
controllers.add(
new LocationServicePreferenceController(context, fragment, lifecycle));
- controllers.add(new LocationScanningPreferenceController(context));
+ controllers.add(new LocationFooterPreferenceController(context, lifecycle));
return controllers;
}
diff --git a/tests/robotests/src/com/android/settings/location/LocationFooterPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/location/LocationFooterPreferenceControllerTest.java
new file mode 100644
index 00000000000..da00010ee53
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/location/LocationFooterPreferenceControllerTest.java
@@ -0,0 +1,220 @@
+/*
+ * Copyright (C) 2018 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 com.android.settings.location;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyInt;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.arch.lifecycle.LifecycleOwner;
+import android.content.ComponentName;
+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.PackageManager.NameNotFoundException;
+import android.content.pm.ResolveInfo;
+import android.content.res.Resources;
+import android.location.LocationManager;
+import android.os.Bundle;
+import android.support.v7.preference.Preference;
+import android.support.v7.preference.PreferenceCategory;
+import com.android.settings.TestConfig;
+import com.android.settings.testutils.SettingsRobolectricTestRunner;
+import com.android.settingslib.core.lifecycle.Lifecycle;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link LocationFooterPreferenceController} */
+@RunWith(SettingsRobolectricTestRunner.class)
+@Config(manifest = TestConfig.MANIFEST_PATH, sdk = TestConfig.SDK_VERSION)
+public class LocationFooterPreferenceControllerTest {
+
+ @Mock
+ private PreferenceCategory mPreferenceCategory;
+ @Mock
+ private PackageManager mPackageManager;
+ @Mock
+ private Resources mResources;
+ private Context mContext;
+ private LocationFooterPreferenceController mController;
+ private LifecycleOwner mLifecycleOwner;
+ private Lifecycle mLifecycle;
+ private static final int TEST_RES_ID = 1234;
+ private static final String TEST_TEXT = "text";
+
+ @Before
+ public void setUp() throws NameNotFoundException {
+ MockitoAnnotations.initMocks(this);
+ mContext = spy(RuntimeEnvironment.application);
+ when(mContext.getPackageManager()).thenReturn(mPackageManager);
+ mLifecycleOwner = () -> mLifecycle;
+ mLifecycle = new Lifecycle(mLifecycleOwner);
+ when(mPreferenceCategory.getContext()).thenReturn(mContext);
+ mController = spy(new LocationFooterPreferenceController(mContext, mLifecycle));
+ when(mPackageManager.getResourcesForApplication(any(ApplicationInfo.class)))
+ .thenReturn(mResources);
+ when(mResources.getString(TEST_RES_ID)).thenReturn(TEST_TEXT);
+ doNothing().when(mPreferenceCategory).removeAll();
+ }
+
+ @Test
+ public void isAvailable_hasValidFooter_returnsTrue() throws NameNotFoundException {
+ final List testResolveInfos = new ArrayList<>();
+ testResolveInfos.add(
+ getTestResolveInfo(/*isSystemApp*/ true, /*hasRequiredMetadata*/ true));
+ when(mPackageManager.queryBroadcastReceivers(any(Intent.class), anyInt()))
+ .thenReturn(testResolveInfos);
+
+ assertThat(mController.isAvailable()).isTrue();
+ }
+
+ @Test
+ public void isAvailable_noSystemApp_returnsFalse() throws NameNotFoundException {
+ final List testResolveInfos = new ArrayList<>();
+ testResolveInfos.add(
+ getTestResolveInfo(/*isSystemApp*/ false, /*hasRequiredMetadata*/ true));
+ when(mPackageManager.queryBroadcastReceivers(any(Intent.class), anyInt()))
+ .thenReturn(testResolveInfos);
+ assertThat(mController.isAvailable()).isFalse();
+ }
+
+ @Test
+ public void isAvailable_noRequiredMetadata_returnsFalse() throws NameNotFoundException {
+ final List testResolveInfos = new ArrayList<>();
+ testResolveInfos.add(
+ getTestResolveInfo(/*isSystemApp*/ true, /*hasRequiredMetadata*/ false));
+ when(mPackageManager.queryBroadcastReceivers(any(Intent.class), anyInt()))
+ .thenReturn(testResolveInfos);
+ assertThat(mController.isAvailable()).isFalse();
+ }
+
+ @Test
+ public void sendBroadcastFooterInject() {
+ ArgumentCaptor intent = ArgumentCaptor.forClass(Intent.class);
+ final ActivityInfo activityInfo =
+ getTestResolveInfo(/*isSystemApp*/ true, /*hasRequiredMetadata*/ true).activityInfo;
+ mController.sendBroadcastFooterDisplayed(
+ new ComponentName(activityInfo.packageName, activityInfo.name));
+ verify(mContext).sendBroadcast(intent.capture());
+ assertThat(intent.getValue().getAction())
+ .isEqualTo(LocationManager.SETTINGS_FOOTER_DISPLAYED_ACTION);
+ }
+
+ @Test
+ public void updateState_sendBroadcast() throws NameNotFoundException {
+ final List testResolveInfos = new ArrayList<>();
+ testResolveInfos.add(
+ getTestResolveInfo(/*isSystemApp*/ true, /*hasRequiredMetadata*/ true));
+ when(mPackageManager.queryBroadcastReceivers(any(), anyInt()))
+ .thenReturn(testResolveInfos);
+ mController.updateState(mPreferenceCategory);
+ ArgumentCaptor intent = ArgumentCaptor.forClass(Intent.class);
+ verify(mContext).sendBroadcast(intent.capture());
+ assertThat(intent.getValue().getAction())
+ .isEqualTo(LocationManager.SETTINGS_FOOTER_DISPLAYED_ACTION);
+ }
+
+ @Test
+ public void updateState_addPreferences() throws NameNotFoundException {
+ final List testResolveInfos = new ArrayList<>();
+ testResolveInfos.add(
+ getTestResolveInfo(/*isSystemApp*/ true, /*hasRequiredMetadata*/ true));
+ when(mPackageManager.queryBroadcastReceivers(any(Intent.class), anyInt()))
+ .thenReturn(testResolveInfos);
+ mController.updateState(mPreferenceCategory);
+ ArgumentCaptor pref = ArgumentCaptor.forClass(Preference.class);
+ verify(mPreferenceCategory).addPreference(pref.capture());
+ assertThat(pref.getValue().getTitle()).isEqualTo(TEST_TEXT);
+ }
+
+ @Test
+ public void updateState_notSystemApp_ignore() throws NameNotFoundException {
+ final List testResolveInfos = new ArrayList<>();
+ testResolveInfos.add(
+ getTestResolveInfo(/*isSystemApp*/ false, /*hasRequiredMetadata*/ true));
+ when(mPackageManager.queryBroadcastReceivers(any(Intent.class), anyInt()))
+ .thenReturn(testResolveInfos);
+ mController.updateState(mPreferenceCategory);
+ verify(mPreferenceCategory, never()).addPreference(any(Preference.class));
+ verify(mContext, never()).sendBroadcast(any(Intent.class));
+ }
+
+ @Test
+ public void updateState_thenOnPause_sendBroadcasts() throws NameNotFoundException {
+ final List testResolveInfos = new ArrayList<>();
+ testResolveInfos.add(
+ getTestResolveInfo(/*isSystemApp*/ true, /*hasRequiredMetadata*/ true));
+ when(mPackageManager.queryBroadcastReceivers(any(Intent.class), anyInt()))
+ .thenReturn(testResolveInfos);
+ mController.updateState(mPreferenceCategory);
+ ArgumentCaptor intent = ArgumentCaptor.forClass(Intent.class);
+ verify(mContext).sendBroadcast(intent.capture());
+ assertThat(intent.getValue().getAction())
+ .isEqualTo(LocationManager.SETTINGS_FOOTER_DISPLAYED_ACTION);
+
+ mController.onPause();
+ verify(mContext, times(2)).sendBroadcast(intent.capture());
+ assertThat(intent.getValue().getAction())
+ .isEqualTo(LocationManager.SETTINGS_FOOTER_REMOVED_ACTION);
+ }
+
+ @Test
+ public void onPause_doNotSendBroadcast() {
+ mController.onPause();
+ verify(mContext, never()).sendBroadcast(any(Intent.class));
+ }
+
+ /**
+ * Returns a ResolveInfo object for testing
+ * @param isSystemApp If true, the application is a system app.
+ * @param hasRequiredMetaData If true, the broadcast receiver has a valid value for
+ * {@link LocationManager#METADATA_SETTINGS_FOOTER_STRING}
+ */
+ private ResolveInfo getTestResolveInfo(boolean isSystemApp, boolean hasRequiredMetaData) {
+ ResolveInfo testResolveInfo = new ResolveInfo();
+ ApplicationInfo testAppInfo = new ApplicationInfo();
+ if (isSystemApp) {
+ testAppInfo.flags |= ApplicationInfo.FLAG_SYSTEM;
+ }
+ ActivityInfo testActivityInfo = new ActivityInfo();
+ testActivityInfo.name = "TestActivityName";
+ testActivityInfo.packageName = "TestPackageName";
+ testActivityInfo.applicationInfo = testAppInfo;
+ if (hasRequiredMetaData) {
+ testActivityInfo.metaData = new Bundle();
+ testActivityInfo.metaData.putInt(
+ LocationManager.METADATA_SETTINGS_FOOTER_STRING, TEST_RES_ID);
+ }
+ testResolveInfo.activityInfo = testActivityInfo;
+ return testResolveInfo;
+ }
+}