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; + } +}