diff --git a/res/values/strings.xml b/res/values/strings.xml index 255996fbb84..1fa85394518 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -686,9 +686,6 @@ Loading\u2026 - Location may use sources like GPS, Wi\u2011Fi, mobile networks, and sensors to help estimate - your device\u2019s location. - <br><br>Apps with the Nearby devices permission can determine the relative position of connected devices. diff --git a/src/com/android/settings/location/LocationSettingsFooterPreferenceController.java b/src/com/android/settings/location/LocationSettingsFooterPreferenceController.java index 48034bdd8ef..d2d5c1ffa5b 100644 --- a/src/com/android/settings/location/LocationSettingsFooterPreferenceController.java +++ b/src/com/android/settings/location/LocationSettingsFooterPreferenceController.java @@ -17,21 +17,42 @@ package com.android.settings.location; 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.ResolveInfo; +import android.location.LocationManager; import android.text.Html; +import android.util.Log; +import androidx.preference.Preference; import androidx.preference.PreferenceScreen; import com.android.settings.R; 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 Settings footer. */ public class LocationSettingsFooterPreferenceController extends LocationBasePreferenceController { - FooterPreference mFooterPreference; + private static final String TAG = "LocationFooter"; + private static final Intent INJECT_INTENT = + new Intent(LocationManager.SETTINGS_FOOTER_DISPLAYED_ACTION); + + private final PackageManager mPackageManager; + private FooterPreference mFooterPreference; + private boolean mLocationEnabled; + private String mInjectedFooterString; public LocationSettingsFooterPreferenceController(Context context, String key) { super(context, key); + mPackageManager = context.getPackageManager(); } @Override @@ -42,9 +63,118 @@ public class LocationSettingsFooterPreferenceController extends LocationBasePref @Override public void onLocationModeChanged(int mode, boolean restricted) { - boolean enabled = mLocationEnabler.isEnabled(mode); - mFooterPreference.setTitle(Html.fromHtml(mContext.getString( - enabled ? R.string.location_settings_footer_location_on - : R.string.location_settings_footer_location_off))); + mLocationEnabled = mLocationEnabler.isEnabled(mode); + updateFooterPreference(); + } + + /** + * Insert footer preferences. + */ + @Override + public void updateState(Preference preference) { + Collection footerData = getFooterData(); + for (FooterData data : footerData) { + try { + mInjectedFooterString = + mPackageManager + .getResourcesForApplication(data.applicationInfo) + .getString(data.footerStringRes); + updateFooterPreference(); + } catch (PackageManager.NameNotFoundException exception) { + Log.w( + TAG, + "Resources not found for application " + + data.applicationInfo.packageName); + } + } + } + + private void updateFooterPreference() { + String footerString = mContext.getString( + mLocationEnabled ? R.string.location_settings_footer_location_on + : R.string.location_settings_footer_location_off); + if (mLocationEnabled) { + footerString = mInjectedFooterString + footerString; + } + if (mFooterPreference != null) { + mFooterPreference.setTitle(Html.fromHtml(footerString)); + } + } + + /** + * Location footer preference group should be displayed if there is at least one footer to + * inject. + */ + @Override + public int getAvailabilityStatus() { + return !getFooterData().isEmpty() ? AVAILABLE : UNSUPPORTED_ON_DEVICE; + } + + /** + * Return a list of strings with text provided by ACTION_INJECT_FOOTER broadcast receivers. + */ + private List getFooterData() { + // Fetch footer text from system apps + List resolveInfos = + mPackageManager.queryBroadcastReceivers( + INJECT_INTENT, PackageManager.GET_META_DATA); + if (resolveInfos == null) { + Log.e(TAG, "Unable to resolve intent " + INJECT_INTENT); + return Collections.emptyList(); + } + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Found broadcast receivers: " + resolveInfos); + } + + List footerDataList = new ArrayList<>(resolveInfos.size()); + for (ResolveInfo resolveInfo : resolveInfos) { + ActivityInfo activityInfo = resolveInfo.activityInfo; + ApplicationInfo appInfo = activityInfo.applicationInfo; + + // If a non-system app tries to inject footer, ignore it + if ((appInfo.flags & ApplicationInfo.FLAG_SYSTEM) == 0) { + 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) { + Log.w( + TAG, + "No mapping of integer exists for " + + LocationManager.METADATA_SETTINGS_FOOTER_STRING); + continue; + } + footerDataList.add(new FooterData(footerTextRes, appInfo)); + } + return footerDataList; + } + + /** + * Contains information related to a footer. + */ + private static class FooterData { + + // The string resource of the footer + public final int footerStringRes; + + // Application info of receiver injecting this footer + public final ApplicationInfo applicationInfo; + + FooterData(int footerRes, ApplicationInfo appInfo) { + this.footerStringRes = footerRes; + this.applicationInfo = appInfo; + } } } diff --git a/tests/robotests/src/com/android/settings/location/LocationSettingsFooterPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/location/LocationSettingsFooterPreferenceControllerTest.java new file mode 100644 index 00000000000..7929b718abe --- /dev/null +++ b/tests/robotests/src/com/android/settings/location/LocationSettingsFooterPreferenceControllerTest.java @@ -0,0 +1,214 @@ +/* + * Copyright (C) 2021 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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyChar; +import static org.mockito.ArgumentMatchers.anyInt; +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.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.text.Html; + +import androidx.lifecycle.LifecycleOwner; +import androidx.preference.PreferenceScreen; + +import com.android.settings.R; +import com.android.settingslib.core.lifecycle.Lifecycle; +import com.android.settingslib.widget.FooterPreference; + +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.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class LocationSettingsFooterPreferenceControllerTest { + + private static final int TEST_RES_ID = 1234; + private static final String TEST_TEXT = "text"; + private static final String PREFERENCE_KEY = "location_footer"; + + private Context mContext; + private LocationSettingsFooterPreferenceController mController; + private Lifecycle mLifecycle; + + @Mock + private PreferenceScreen mPreferenceScreen; + @Mock + private FooterPreference mFooterPreference; + @Mock + private PackageManager mPackageManager; + @Mock + private Resources mResources; + + @Before + public void setUp() throws NameNotFoundException { + MockitoAnnotations.initMocks(this); + mContext = spy(RuntimeEnvironment.application); + when(mContext.getPackageManager()).thenReturn(mPackageManager); + + LifecycleOwner lifecycleOwner = () -> mLifecycle; + mLifecycle = new Lifecycle(lifecycleOwner); + LocationSettings locationSettings = spy(new LocationSettings()); + when(locationSettings.getSettingsLifecycle()).thenReturn(mLifecycle); + + mController = spy(new LocationSettingsFooterPreferenceController(mContext, PREFERENCE_KEY)); + mController.init(locationSettings); + + when(mPreferenceScreen.findPreference(PREFERENCE_KEY)).thenReturn(mFooterPreference); + when(mPackageManager.getResourcesForApplication(any(ApplicationInfo.class))) + .thenReturn(mResources); + when(mResources.getString(TEST_RES_ID)).thenReturn(TEST_TEXT); + mController.displayPreference(mPreferenceScreen); + } + + @Test + public void isAvailable_hasValidFooter_returnsTrue() { + 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() { + 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() { + 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 updateState_setTitle() { + final List testResolveInfos = new ArrayList<>(); + testResolveInfos.add( + getTestResolveInfo(/*isSystemApp*/ true, /*hasRequiredMetadata*/ true)); + when(mPackageManager.queryBroadcastReceivers(any(Intent.class), anyInt())) + .thenReturn(testResolveInfos); + mController.updateState(mFooterPreference); + ArgumentCaptor title = ArgumentCaptor.forClass(CharSequence.class); + verify(mFooterPreference).setTitle(title.capture()); + assertThat(title.getValue()).isNotNull(); + } + + @Test + public void onLocationModeChanged_off_setTitle() { + final List testResolveInfos = new ArrayList<>(); + testResolveInfos.add( + getTestResolveInfo(/*isSystemApp*/ true, /*hasRequiredMetadata*/ true)); + when(mPackageManager.queryBroadcastReceivers(any(Intent.class), anyInt())) + .thenReturn(testResolveInfos); + mController.updateState(mFooterPreference); + verify(mFooterPreference).setTitle(any()); + mController.onLocationModeChanged(/* mode= */ 0, /* restricted= */ false); + ArgumentCaptor title = ArgumentCaptor.forClass(CharSequence.class); + verify(mFooterPreference, times(2)).setTitle(title.capture()); + assertThat(title.getValue().toString()).isEqualTo( + Html.fromHtml(mContext.getString( + R.string.location_settings_footer_location_off)).toString()); + } + + @Test + public void onLocationModeChanged_on_setTitle() { + final List testResolveInfos = new ArrayList<>(); + testResolveInfos.add( + getTestResolveInfo(/*isSystemApp*/ true, /*hasRequiredMetadata*/ true)); + when(mPackageManager.queryBroadcastReceivers(any(Intent.class), anyInt())) + .thenReturn(testResolveInfos); + mController.updateState(mFooterPreference); + verify(mFooterPreference).setTitle(any()); + mController.onLocationModeChanged(/* mode= */ 1, /* restricted= */ false); + ArgumentCaptor title = ArgumentCaptor.forClass(CharSequence.class); + verify(mFooterPreference, times(2)).setTitle(title.capture()); + assertThat(title.getValue().toString()).isNotEqualTo( + Html.fromHtml(mContext.getString( + R.string.location_settings_footer_location_off)).toString()); + } + + @Test + public void updateState_notSystemApp_ignore() { + final List testResolveInfos = new ArrayList<>(); + testResolveInfos.add( + getTestResolveInfo(/*isSystemApp*/ false, /*hasRequiredMetadata*/ true)); + when(mPackageManager.queryBroadcastReceivers(any(Intent.class), anyInt())) + .thenReturn(testResolveInfos); + mController.updateState(mFooterPreference); + verify(mFooterPreference, never()).setTitle(anyChar()); + } + + /** + * 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; + } +}