blob: a62248e3160884c1a2b54e11911b0c272b3336f2 [file] [log] [blame]
/*
* 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 android.permission.cts;
import static android.Manifest.permission.ACCESS_BACKGROUND_LOCATION;
import static android.Manifest.permission.ACCESS_FINE_LOCATION;
import static android.app.Notification.EXTRA_TITLE;
import static android.content.Context.BIND_AUTO_CREATE;
import static android.content.Intent.ACTION_BOOT_COMPLETED;
import static android.location.Criteria.ACCURACY_FINE;
import static android.provider.Settings.RESET_MODE_PACKAGE_DEFAULTS;
import static android.provider.Settings.Secure.LOCATION_ACCESS_CHECK_DELAY_MILLIS;
import static android.provider.Settings.Secure.LOCATION_ACCESS_CHECK_INTERVAL_MILLIS;
import static com.android.compatibility.common.util.SystemUtil.runShellCommand;
import static com.android.compatibility.common.util.SystemUtil.runWithShellPermissionIdentity;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.fail;
import static org.junit.Assume.assumeFalse;
import static org.junit.Assume.assumeTrue;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import android.app.ActivityManager;
import android.app.AppOpsManager;
import android.app.UiAutomation;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.pm.ResolveInfo;
import android.location.Criteria;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;
import android.os.Debug;
import android.os.IBinder;
import android.os.Looper;
import android.os.ParcelFileDescriptor;
import android.platform.test.annotations.AppModeFull;
import android.provider.DeviceConfig;
import android.provider.Settings;
import android.service.notification.NotificationListenerService;
import android.service.notification.StatusBarNotification;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.test.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;
import com.android.server.job.nano.JobSchedulerServiceDumpProto;
import com.android.server.job.nano.JobSchedulerServiceDumpProto.RegisteredJob;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
/**
* Tests the {@code LocationAccessCheck} in permission controller.
*/
@RunWith(AndroidJUnit4.class)
@AppModeFull(reason = "Cannot set system settings as instant app. Also we never show a location "
+ "access check notification for instant apps.")
public class LocationAccessCheckTest {
private static final String LOG_TAG = LocationAccessCheckTest.class.getSimpleName();
private static final String TEST_APP_PKG = "android.permission.cts.appthataccesseslocation";
private static final String TEST_APP_LABEL = "CtsLocationAccess";
private static final String TEST_APP_SERVICE = TEST_APP_PKG + ".AccessLocationOnCommand";
private static final String TEST_APP_LOCATION_BG_ACCESS_APK =
"/data/local/tmp/cts/permissions/CtsAppThatAccessesLocationOnCommand.apk";
private static final String TEST_APP_LOCATION_FG_ACCESS_APK =
"/data/local/tmp/cts/permissions/AppThatDoesNotHaveBgLocationAccess.apk";
/** Whether to show location access check notifications. */
private static final String PROPERTY_LOCATION_ACCESS_CHECK_ENABLED = "location_access_check_enabled";
private static final long UNEXPECTED_TIMEOUT_MILLIS = 10000;
private static final long EXPECTED_TIMEOUT_MILLIS = 1000;
private static final long LOCATION_ACCESS_TIMEOUT_MILLIS = 15000;
// Same as in AccessLocationOnCommand
private static final long BACKGROUND_ACCESS_SETTLE_TIME = 11000;
private static final Context sContext = InstrumentationRegistry.getTargetContext();
private static final ActivityManager sActivityManager =
(ActivityManager) sContext.getSystemService(Context.ACTIVITY_SERVICE);
private static final UiAutomation sUiAutomation = InstrumentationRegistry.getInstrumentation()
.getUiAutomation();
private static final String PERMISSION_CONTROLLER_PKG = sContext.getPackageManager()
.getPermissionControllerPackageName();
/**
* The result of {@link #assumeCanGetFineLocation()}, so we don't have to run it over and over
* again.
*/
private static Boolean sCanAccessFineLocation = null;
private ServiceConnection mConnection;
/**
* Connected to {@value #TEST_APP_PKG} and make it access the location in the background
*/
private void accessLocation() {
mConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
// ignore
}
@Override
public void onServiceDisconnected(ComponentName name) {
// ignore
}
};
// Connect and disconnect to service. After the service is disconnected it causes a
// access to the location
Intent testAppService = new Intent();
testAppService.setComponent(new ComponentName(TEST_APP_PKG, TEST_APP_SERVICE));
sContext.bindService(testAppService, mConnection, BIND_AUTO_CREATE);
}
/**
* A {@link java.util.concurrent.Callable} that can throw a {@link Throwable}
*/
private interface ThrowingCallable<T> {
T call() throws Throwable;
}
/**
* A {@link Runnable} that can throw a {@link Throwable}
*/
private interface ThrowingRunnable {
void run() throws Throwable;
}
/**
* Make sure that a {@link ThrowingRunnable} eventually finishes without throwing a {@link
* Exception}.
*
* @param r The {@link ThrowingRunnable} to run.
* @param timeout the maximum time to wait
*/
public static void eventually(@NonNull ThrowingRunnable r, long timeout) throws Throwable {
eventually(() -> {
r.run();
return 0;
}, timeout);
}
/**
* Make sure that a {@link ThrowingCallable} eventually finishes without throwing a {@link
* Exception}.
*
* @param r The {@link ThrowingCallable} to run.
* @param timeout the maximum time to wait
*
* @return the return value from the callable
*
* @throws NullPointerException If the return value never becomes non-null
*/
public static <T> T eventually(@NonNull ThrowingCallable<T> r, long timeout) throws Throwable {
long start = System.currentTimeMillis();
while (true) {
try {
T res = r.call();
if (res == null) {
throw new NullPointerException("No result");
}
return res;
} catch (Throwable e) {
if (System.currentTimeMillis() - start < timeout) {
Log.d(LOG_TAG, "Ignoring exception", e);
Thread.sleep(500);
} else {
throw e;
}
}
}
}
/**
* Get the state of the job scheduler
*/
public static JobSchedulerServiceDumpProto getJobSchedulerDump() throws Exception {
ParcelFileDescriptor pfd = sUiAutomation.executeShellCommand("dumpsys jobscheduler --proto");
try (ByteArrayOutputStream os = new ByteArrayOutputStream()) {
// Copy data from 'is' into 'os'
try (FileInputStream is = new ParcelFileDescriptor.AutoCloseInputStream(pfd)) {
byte[] buffer = new byte[16384];
while (true) {
int numRead = is.read(buffer);
if (numRead == -1) {
break;
} else {
os.write(buffer, 0, numRead);
}
}
}
return JobSchedulerServiceDumpProto.parseFrom(os.toByteArray());
}
}
/**
* Clear all data of a package including permissions and files.
*
* @param pkg The name of the package to be cleared
*/
private static void clearPackageData(@NonNull String pkg) {
runShellCommand("pm clear --user -2 " + pkg);
}
/**
* Force a run of the location check.
*/
private static void runLocationCheck() {
runShellCommand(
"cmd jobscheduler run -u " + android.os.Process.myUserHandle().getIdentifier()
+ " -f " + PERMISSION_CONTROLLER_PKG + " 0");
}
/**
* Get a notification thrown by the permission controller that is currently visible.
*
* @return The notification or {@code null} if there is none
*/
private @Nullable StatusBarNotification getPermissionControllerNotification() throws Exception {
NotificationListenerService notificationService = NotificationListener.getInstance();
for (StatusBarNotification notification : notificationService.getActiveNotifications()) {
if (notification.getPackageName().equals(PERMISSION_CONTROLLER_PKG)) {
return notification;
}
}
return null;
}
/**
* Get a location access notification that is currently visible.
*
* @param cancelNotification if {@code true} the notification is canceled inside this method
*
* @return The notification or {@code null} if there is none
*/
private StatusBarNotification getNotification(boolean cancelNotification) throws Throwable {
NotificationListenerService notificationService = NotificationListener.getInstance();
long start = System.currentTimeMillis();
while (true) {
runLocationCheck();
StatusBarNotification notification = getPermissionControllerNotification();
if (notification == null) {
// Sometimes getting a location takes some time, hence not getting a notification
// can be caused by not having gotten a location yet
if (System.currentTimeMillis() - start < LOCATION_ACCESS_TIMEOUT_MILLIS
+ BACKGROUND_ACCESS_SETTLE_TIME) {
Thread.sleep(200);
continue;
}
return null;
}
if (notification.getNotification().extras.getString(EXTRA_TITLE, "")
.contains(TEST_APP_LABEL)) {
if (cancelNotification) {
notificationService.cancelNotification(notification.getKey());
// Wait for notification to get canceled
eventually(() -> assertFalse(
Arrays.asList(notificationService.getActiveNotifications()).contains(
notification)), UNEXPECTED_TIMEOUT_MILLIS);
}
return notification;
} else {
notificationService.cancelNotification(notification.getKey());
// Wait until new notification can be shown
Thread.sleep(200);
}
}
}
/**
* Grant a permission to the {@value #TEST_APP_PKG}.
*
* @param permission The permission to grant
*/
private void grantPermissionToTestApp(@NonNull String permission) {
sUiAutomation.grantRuntimePermission(TEST_APP_PKG, permission);
}
/**
* Register {@link NotificationListener}.
*/
@BeforeClass
public static void allowNotificationAccess() {
runShellCommand("cmd notification allow_listener " + (new ComponentName(sContext,
NotificationListener.class).flattenToString()));
}
/**
* Change settings so that permission controller can show location access notifications more
* often.
*/
@BeforeClass
public static void reduceDelays() {
runWithShellPermissionIdentity(() -> {
ContentResolver cr = sContext.getContentResolver();
// New settings will be applied in when permission controller is reset
Settings.Secure.putLong(cr, LOCATION_ACCESS_CHECK_INTERVAL_MILLIS, 100);
Settings.Secure.putLong(cr, LOCATION_ACCESS_CHECK_DELAY_MILLIS, 50);
});
}
@BeforeClass
public static void installBackgroundAccessApp() {
runShellCommand("pm install -r -g " + TEST_APP_LOCATION_BG_ACCESS_APK);
}
@AfterClass
public static void uninstallBackgroundAccessApp() {
runShellCommand("pm uninstall " + TEST_APP_PKG);
}
private static void installForegroundAccessApp() {
runShellCommand("pm install -r -g " + TEST_APP_LOCATION_FG_ACCESS_APK);
}
/**
* Skip each test for low ram device
*/
@Before
public void assumeIsNotLowRamDevice() {
assumeFalse(sActivityManager.isLowRamDevice());
}
/**
* Reset the permission controllers state before each test
*/
@Before
public void resetPermissionControllerBeforeEachTest() throws Throwable {
resetPermissionController();
}
/**
* Enable location access check
*/
@Before
public void enableLocationAccessCheck() {
runWithShellPermissionIdentity(() -> DeviceConfig.setProperty(
DeviceConfig.NAMESPACE_PRIVACY,
PROPERTY_LOCATION_ACCESS_CHECK_ENABLED, "true", false));
}
/**
* Disable location access check
*/
private void disableLocationAccessCheck() {
runWithShellPermissionIdentity(() -> DeviceConfig.setProperty(
DeviceConfig.NAMESPACE_PRIVACY,
PROPERTY_LOCATION_ACCESS_CHECK_ENABLED, "false", false));
}
/**
* Make sure fine location can be accessed at all.
*/
@Before
public void assumeCanGetFineLocation() {
if (sCanAccessFineLocation == null) {
Criteria crit = new Criteria();
crit.setAccuracy(ACCURACY_FINE);
CountDownLatch locationCounter = new CountDownLatch(1);
sContext.getSystemService(LocationManager.class).requestSingleUpdate(crit,
new LocationListener() {
@Override
public void onLocationChanged(Location location) {
locationCounter.countDown();
}
@Override
public void onStatusChanged(String provider, int status, Bundle extras) {
}
@Override
public void onProviderEnabled(String provider) {
}
@Override
public void onProviderDisabled(String provider) {
}
}, Looper.getMainLooper());
try {
sCanAccessFineLocation = locationCounter.await(LOCATION_ACCESS_TIMEOUT_MILLIS,
MILLISECONDS);
} catch (InterruptedException ignored) {
}
}
assumeTrue(sCanAccessFineLocation);
}
/**
* Reset the permission controllers state.
*/
private static void resetPermissionController() throws Throwable {
clearPackageData(PERMISSION_CONTROLLER_PKG);
int currentUserId = android.os.Process.myUserHandle().getIdentifier();
// Wait until jobs are cleared
eventually(() -> {
JobSchedulerServiceDumpProto dump = getJobSchedulerDump();
for (RegisteredJob job : dump.registeredJobs) {
if (job.dump.sourceUserId == currentUserId) {
assertNotEquals(job.dump.sourcePackageName, PERMISSION_CONTROLLER_PKG);
}
}
}, UNEXPECTED_TIMEOUT_MILLIS);
// Setup up permission controller again (simulate a reboot)
Intent permissionControllerSetupIntent = null;
for (ResolveInfo ri : sContext.getPackageManager().queryBroadcastReceivers(
new Intent(ACTION_BOOT_COMPLETED), 0)) {
String pkg = ri.activityInfo.packageName;
if (pkg.equals(PERMISSION_CONTROLLER_PKG)) {
permissionControllerSetupIntent = new Intent();
permissionControllerSetupIntent.setClassName(pkg, ri.activityInfo.name);
}
}
if (permissionControllerSetupIntent != null) {
sContext.sendBroadcast(permissionControllerSetupIntent);
}
// Wait until jobs are set up
eventually(() -> {
JobSchedulerServiceDumpProto dump = getJobSchedulerDump();
for (RegisteredJob job : dump.registeredJobs) {
if (job.dump.sourceUserId == currentUserId
&& job.dump.sourcePackageName.equals(PERMISSION_CONTROLLER_PKG)) {
return;
}
}
fail("Permission controller jobs not found");
}, UNEXPECTED_TIMEOUT_MILLIS);
}
/**
* Unregister {@link NotificationListener}.
*/
@AfterClass
public static void disallowNotificationAccess() {
runShellCommand("cmd notification disallow_listener " + (new ComponentName(sContext,
NotificationListener.class)).flattenToString());
}
/**
* Reset settings so that permission controller runs normally.
*/
@AfterClass
public static void resetDelays() throws Throwable {
runWithShellPermissionIdentity(() -> {
ContentResolver cr = sContext.getContentResolver();
Settings.Secure.resetToDefaults(cr, LOCATION_ACCESS_CHECK_INTERVAL_MILLIS);
Settings.Secure.resetToDefaults(cr, LOCATION_ACCESS_CHECK_DELAY_MILLIS);
});
resetPermissionController();
}
/**
* Reset location access check
*/
@After
public void resetPrivacyConfig() {
runWithShellPermissionIdentity(
() -> DeviceConfig.resetToDefaults(RESET_MODE_PACKAGE_DEFAULTS,
DeviceConfig.NAMESPACE_PRIVACY));
}
@After
public void locationUnbind() {
if (mConnection != null) {
sContext.unbindService(mConnection);
}
}
@Test
public void notificationIsShown() throws Throwable {
accessLocation();
assertNotNull(getNotification(true));
}
@Test
public void notificationIsShownOnlyOnce() throws Throwable {
accessLocation();
getNotification(true);
assertNull(getNotification(true));
}
@Test
public void notificationIsShownAgainAfterClear() throws Throwable {
accessLocation();
getNotification(true);
clearPackageData(TEST_APP_PKG);
// Wait until package is cleared and permission controller has cleared the state
Thread.sleep(10000);
// Clearing removed the permissions, hence grant them again
grantPermissionToTestApp(ACCESS_FINE_LOCATION);
grantPermissionToTestApp(ACCESS_BACKGROUND_LOCATION);
accessLocation();
assertNotNull(getNotification(true));
}
@Test
public void notificationIsShownAgainAfterUninstallAndReinstall() throws Throwable {
accessLocation();
getNotification(true);
uninstallBackgroundAccessApp();
// Wait until package permission controller has cleared the state
Thread.sleep(2000);
installBackgroundAccessApp();
eventually(() -> {
accessLocation();
assertNotNull(getNotification(false));
}, UNEXPECTED_TIMEOUT_MILLIS);
}
@Test
public void removeNotificationOnUninstall() throws Throwable {
accessLocation();
getNotification(false);
uninstallBackgroundAccessApp();
try {
eventually(() -> assertNull(getNotification(false)), UNEXPECTED_TIMEOUT_MILLIS);
} finally {
installBackgroundAccessApp();
getNotification(true);
}
}
@Test
public void notificationIsNotShownAfterAppDoesNotRequestLocationAnymore() throws Throwable {
accessLocation();
getNotification(true);
// Update to app to a version that does not request permission anymore
installForegroundAccessApp();
try {
resetPermissionController();
try {
// We don't expect a notification, but try to trigger one anyway
eventually(() -> assertNotNull(getNotification(false)), EXPECTED_TIMEOUT_MILLIS);
} catch (AssertionError expected) {
return;
}
fail("Location access notification was shown");
} finally {
installBackgroundAccessApp();
}
}
@Test
public void noNotificationIfFeatureDisabled() throws Throwable {
disableLocationAccessCheck();
accessLocation();
assertNull(getNotification(true));
}
@Test
public void notificationOnlyForAccessesSinceFeatureWasEnabled() throws Throwable {
// Disable the feature and access location in disabled state
disableLocationAccessCheck();
accessLocation();
assertNull(getNotification(true));
// No notification expected for accesses before enabling the feature
enableLocationAccessCheck();
assertNull(getNotification(true));
// Notification expected for access after enabling the feature
accessLocation();
assertNotNull(getNotification(true));
}
@Test
public void noNotificationIfBlamerNotSystemOrLocationProvider() throws Throwable {
// Blame the app for access from an untrusted for notification purposes package.
runWithShellPermissionIdentity(() -> {
AppOpsManager appOpsManager = sContext.getSystemService(AppOpsManager.class);
appOpsManager.noteProxyOpNoThrow(AppOpsManager.OPSTR_FINE_LOCATION, TEST_APP_PKG,
sContext.getPackageManager().getPackageUid(TEST_APP_PKG, 0));
});
assertNull(getNotification(true));
}
@Test
public void testOpeningLocationSettingsDoesNotTriggerAccess() throws Throwable {
Intent intent = new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
sContext.startActivity(intent);
assertNull(getNotification(true));
}
}