blob: b3bad57f13002847ea77e961589f2684efd5056d [file] [log] [blame]
/*
* Copyright (C) 2017 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.server.companion;
import static com.android.internal.util.CollectionUtils.size;
import static com.android.internal.util.Preconditions.checkArgument;
import static com.android.internal.util.Preconditions.checkNotNull;
import static com.android.internal.util.Preconditions.checkState;
import android.Manifest;
import android.annotation.CheckResult;
import android.annotation.Nullable;
import android.app.PendingIntent;
import android.companion.AssociationRequest;
import android.companion.CompanionDeviceManager;
import android.companion.ICompanionDeviceDiscoveryService;
import android.companion.ICompanionDeviceDiscoveryServiceCallback;
import android.companion.ICompanionDeviceManager;
import android.companion.IFindDeviceCallback;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.pm.FeatureInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.net.NetworkPolicyManager;
import android.os.Binder;
import android.os.Environment;
import android.os.Handler;
import android.os.IBinder;
import android.os.IDeviceIdleController;
import android.os.IInterface;
import android.os.Parcel;
import android.os.Process;
import android.os.RemoteException;
import android.os.ResultReceiver;
import android.os.ServiceManager;
import android.os.ShellCallback;
import android.os.ShellCommand;
import android.os.UserHandle;
import android.provider.Settings;
import android.provider.SettingsStringUtil.ComponentNameSet;
import android.text.BidiFormatter;
import android.util.AtomicFile;
import android.util.ExceptionUtils;
import android.util.Log;
import android.util.Slog;
import android.util.Xml;
import com.android.internal.app.IAppOpsService;
import com.android.internal.content.PackageMonitor;
import com.android.internal.notification.NotificationAccessConfirmationActivityContract;
import com.android.internal.util.ArrayUtils;
import com.android.internal.util.CollectionUtils;
import com.android.server.FgThread;
import com.android.server.SystemService;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlSerializer;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Function;
//TODO onStop schedule unbind in 5 seconds
//TODO make sure APIs are only callable from currently focused app
//TODO schedule stopScan on activity destroy(except if configuration change)
//TODO on associate called again after configuration change -> replace old callback with new
//TODO avoid leaking calling activity in IFindDeviceCallback (see PrintManager#print for example)
/** @hide */
public class CompanionDeviceManagerService extends SystemService implements Binder.DeathRecipient {
private static final ComponentName SERVICE_TO_BIND_TO = ComponentName.createRelative(
CompanionDeviceManager.COMPANION_DEVICE_DISCOVERY_PACKAGE_NAME,
".DeviceDiscoveryService");
private static final boolean DEBUG = false;
private static final String LOG_TAG = "CompanionDeviceManagerService";
private static final String XML_TAG_ASSOCIATIONS = "associations";
private static final String XML_TAG_ASSOCIATION = "association";
private static final String XML_ATTR_PACKAGE = "package";
private static final String XML_ATTR_DEVICE = "device";
private static final String XML_FILE_NAME = "companion_device_manager_associations.xml";
private final CompanionDeviceManagerImpl mImpl;
private final ConcurrentMap<Integer, AtomicFile> mUidToStorage = new ConcurrentHashMap<>();
private IDeviceIdleController mIdleController;
private ServiceConnection mServiceConnection;
private IAppOpsService mAppOpsManager;
private IFindDeviceCallback mFindDeviceCallback;
private AssociationRequest mRequest;
private String mCallingPackage;
private final Object mLock = new Object();
public CompanionDeviceManagerService(Context context) {
super(context);
mImpl = new CompanionDeviceManagerImpl();
mIdleController = IDeviceIdleController.Stub.asInterface(
ServiceManager.getService(Context.DEVICE_IDLE_CONTROLLER));
mAppOpsManager = IAppOpsService.Stub.asInterface(
ServiceManager.getService(Context.APP_OPS_SERVICE));
registerPackageMonitor();
}
private void registerPackageMonitor() {
new PackageMonitor() {
@Override
public void onPackageRemoved(String packageName, int uid) {
updateAssociations(
as -> CollectionUtils.filter(as,
a -> !Objects.equals(a.companionAppPackage, packageName)),
getChangingUserId());
}
@Override
public void onPackageModified(String packageName) {
int userId = getChangingUserId();
if (!ArrayUtils.isEmpty(readAllAssociations(userId, packageName))) {
updateSpecialAccessPermissionForAssociatedPackage(packageName, userId);
}
}
}.register(getContext(), FgThread.get().getLooper(), UserHandle.ALL, true);
}
@Override
public void onStart() {
publishBinderService(Context.COMPANION_DEVICE_SERVICE, mImpl);
}
@Override
public void binderDied() {
Handler.getMain().post(this::cleanup);
}
private void cleanup() {
synchronized (mLock) {
mServiceConnection = unbind(mServiceConnection);
mFindDeviceCallback = unlinkToDeath(mFindDeviceCallback, this, 0);
mRequest = null;
mCallingPackage = null;
}
}
/**
* Usage: {@code a = unlinkToDeath(a, deathRecipient, flags); }
*/
@Nullable
@CheckResult
private static <T extends IInterface> T unlinkToDeath(T iinterface,
IBinder.DeathRecipient deathRecipient, int flags) {
if (iinterface != null) {
iinterface.asBinder().unlinkToDeath(deathRecipient, flags);
}
return null;
}
@Nullable
@CheckResult
private ServiceConnection unbind(@Nullable ServiceConnection conn) {
if (conn != null) {
getContext().unbindService(conn);
}
return null;
}
class CompanionDeviceManagerImpl extends ICompanionDeviceManager.Stub {
@Override
public boolean onTransact(int code, Parcel data, Parcel reply, int flags)
throws RemoteException {
try {
return super.onTransact(code, data, reply, flags);
} catch (Throwable e) {
Slog.e(LOG_TAG, "Error during IPC", e);
throw ExceptionUtils.propagate(e, RemoteException.class);
}
}
@Override
public void associate(
AssociationRequest request,
IFindDeviceCallback callback,
String callingPackage) throws RemoteException {
if (DEBUG) {
Slog.i(LOG_TAG, "associate(request = " + request + ", callback = " + callback
+ ", callingPackage = " + callingPackage + ")");
}
checkNotNull(request, "Request cannot be null");
checkNotNull(callback, "Callback cannot be null");
checkCallerIsSystemOr(callingPackage);
int userId = getCallingUserId();
checkUsesFeature(callingPackage, userId);
final long callingIdentity = Binder.clearCallingIdentity();
try {
getContext().bindServiceAsUser(
new Intent().setComponent(SERVICE_TO_BIND_TO),
createServiceConnection(request, callback, callingPackage),
Context.BIND_AUTO_CREATE,
UserHandle.of(userId));
} finally {
Binder.restoreCallingIdentity(callingIdentity);
}
}
@Override
public void stopScan(AssociationRequest request,
IFindDeviceCallback callback,
String callingPackage) {
if (Objects.equals(request, mRequest)
&& Objects.equals(callback, mFindDeviceCallback)
&& Objects.equals(callingPackage, mCallingPackage)) {
cleanup();
}
}
@Override
public List<String> getAssociations(String callingPackage, int userId)
throws RemoteException {
checkCallerIsSystemOr(callingPackage, userId);
checkUsesFeature(callingPackage, getCallingUserId());
return CollectionUtils.map(
readAllAssociations(userId, callingPackage),
a -> a.deviceAddress);
}
//TODO also revoke notification access
@Override
public void disassociate(String deviceMacAddress, String callingPackage)
throws RemoteException {
checkNotNull(deviceMacAddress);
checkCallerIsSystemOr(callingPackage);
checkUsesFeature(callingPackage, getCallingUserId());
removeAssociation(getCallingUserId(), callingPackage, deviceMacAddress);
}
private void checkCallerIsSystemOr(String pkg) throws RemoteException {
checkCallerIsSystemOr(pkg, getCallingUserId());
}
private void checkCallerIsSystemOr(String pkg, int userId) throws RemoteException {
if (isCallerSystem()) {
return;
}
checkArgument(getCallingUserId() == userId,
"Must be called by either same user or system");
mAppOpsManager.checkPackage(Binder.getCallingUid(), pkg);
}
@Override
public PendingIntent requestNotificationAccess(ComponentName component)
throws RemoteException {
String callingPackage = component.getPackageName();
checkCanCallNotificationApi(callingPackage);
int userId = getCallingUserId();
String packageTitle = BidiFormatter.getInstance().unicodeWrap(
getPackageInfo(callingPackage, userId)
.applicationInfo
.loadSafeLabel(getContext().getPackageManager())
.toString());
long identity = Binder.clearCallingIdentity();
try {
return PendingIntent.getActivity(getContext(),
0 /* request code */,
NotificationAccessConfirmationActivityContract.launcherIntent(
userId, component, packageTitle),
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT
| PendingIntent.FLAG_CANCEL_CURRENT);
} finally {
Binder.restoreCallingIdentity(identity);
}
}
@Override
public boolean hasNotificationAccess(ComponentName component) throws RemoteException {
checkCanCallNotificationApi(component.getPackageName());
String setting = Settings.Secure.getString(getContext().getContentResolver(),
Settings.Secure.ENABLED_NOTIFICATION_LISTENERS);
return new ComponentNameSet(setting).contains(component);
}
private void checkCanCallNotificationApi(String callingPackage) throws RemoteException {
checkCallerIsSystemOr(callingPackage);
int userId = getCallingUserId();
checkState(!ArrayUtils.isEmpty(readAllAssociations(userId, callingPackage)),
"App must have an association before calling this API");
checkUsesFeature(callingPackage, userId);
}
private void checkUsesFeature(String pkg, int userId) {
if (isCallerSystem()) {
// Drop the requirement for calls from system process
return;
}
FeatureInfo[] reqFeatures = getPackageInfo(pkg, userId).reqFeatures;
String requiredFeature = PackageManager.FEATURE_COMPANION_DEVICE_SETUP;
int numFeatures = ArrayUtils.size(reqFeatures);
for (int i = 0; i < numFeatures; i++) {
if (requiredFeature.equals(reqFeatures[i].name)) return;
}
throw new IllegalStateException("Must declare uses-feature "
+ requiredFeature
+ " in manifest to use this API");
}
@Override
public void onShellCommand(FileDescriptor in, FileDescriptor out, FileDescriptor err,
String[] args, ShellCallback callback, ResultReceiver resultReceiver)
throws RemoteException {
new ShellCmd().exec(this, in, out, err, args, callback, resultReceiver);
}
}
private static int getCallingUserId() {
return UserHandle.getUserId(Binder.getCallingUid());
}
private static boolean isCallerSystem() {
return Binder.getCallingUid() == Process.SYSTEM_UID;
}
private ServiceConnection createServiceConnection(
final AssociationRequest request,
final IFindDeviceCallback findDeviceCallback,
final String callingPackage) {
mServiceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
if (DEBUG) {
Slog.i(LOG_TAG,
"onServiceConnected(name = " + name + ", service = "
+ service + ")");
}
mFindDeviceCallback = findDeviceCallback;
mRequest = request;
mCallingPackage = callingPackage;
try {
mFindDeviceCallback.asBinder().linkToDeath(
CompanionDeviceManagerService.this, 0);
} catch (RemoteException e) {
cleanup();
return;
}
try {
ICompanionDeviceDiscoveryService.Stub
.asInterface(service)
.startDiscovery(
request,
callingPackage,
findDeviceCallback,
getServiceCallback());
} catch (RemoteException e) {
Log.e(LOG_TAG, "Error while initiating device discovery", e);
}
}
@Override
public void onServiceDisconnected(ComponentName name) {
if (DEBUG) Slog.i(LOG_TAG, "onServiceDisconnected(name = " + name + ")");
}
};
return mServiceConnection;
}
private ICompanionDeviceDiscoveryServiceCallback.Stub getServiceCallback() {
return new ICompanionDeviceDiscoveryServiceCallback.Stub() {
@Override
public boolean onTransact(int code, Parcel data, Parcel reply, int flags)
throws RemoteException {
try {
return super.onTransact(code, data, reply, flags);
} catch (Throwable e) {
Slog.e(LOG_TAG, "Error during IPC", e);
throw ExceptionUtils.propagate(e, RemoteException.class);
}
}
@Override
public void onDeviceSelected(String packageName, int userId, String deviceAddress) {
addAssociation(userId, packageName, deviceAddress);
cleanup();
}
@Override
public void onDeviceSelectionCancel() {
cleanup();
}
};
}
void addAssociation(int userId, String packageName, String deviceAddress) {
updateSpecialAccessPermissionForAssociatedPackage(packageName, userId);
recordAssociation(packageName, deviceAddress);
}
void removeAssociation(int userId, String pkg, String deviceMacAddress) {
updateAssociations(associations -> CollectionUtils.remove(associations,
new Association(userId, deviceMacAddress, pkg)));
}
private void updateSpecialAccessPermissionForAssociatedPackage(String packageName, int userId) {
PackageInfo packageInfo = getPackageInfo(packageName, userId);
if (packageInfo == null) {
return;
}
Binder.withCleanCallingIdentity(() -> {
try {
if (containsEither(packageInfo.requestedPermissions,
Manifest.permission.RUN_IN_BACKGROUND,
Manifest.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND)) {
mIdleController.addPowerSaveWhitelistApp(packageInfo.packageName);
} else {
mIdleController.removePowerSaveWhitelistApp(packageInfo.packageName);
}
} catch (RemoteException e) {
/* ignore - local call */
}
NetworkPolicyManager networkPolicyManager = NetworkPolicyManager.from(getContext());
if (containsEither(packageInfo.requestedPermissions,
Manifest.permission.USE_DATA_IN_BACKGROUND,
Manifest.permission.REQUEST_COMPANION_USE_DATA_IN_BACKGROUND)) {
networkPolicyManager.addUidPolicy(
packageInfo.applicationInfo.uid,
NetworkPolicyManager.POLICY_ALLOW_METERED_BACKGROUND);
} else {
networkPolicyManager.removeUidPolicy(
packageInfo.applicationInfo.uid,
NetworkPolicyManager.POLICY_ALLOW_METERED_BACKGROUND);
}
});
}
private static <T> boolean containsEither(T[] array, T a, T b) {
return ArrayUtils.contains(array, a) || ArrayUtils.contains(array, b);
}
@Nullable
private PackageInfo getPackageInfo(String packageName, int userId) {
return Binder.withCleanCallingIdentity(() -> {
try {
return getContext().getPackageManager().getPackageInfoAsUser(
packageName,
PackageManager.GET_PERMISSIONS | PackageManager.GET_CONFIGURATIONS,
userId);
} catch (PackageManager.NameNotFoundException e) {
Slog.e(LOG_TAG, "Failed to get PackageInfo for package " + packageName, e);
return null;
}
});
}
private void recordAssociation(String priviledgedPackage, String deviceAddress) {
if (DEBUG) {
Log.i(LOG_TAG, "recordAssociation(priviledgedPackage = " + priviledgedPackage
+ ", deviceAddress = " + deviceAddress + ")");
}
int userId = getCallingUserId();
updateAssociations(associations -> CollectionUtils.add(associations,
new Association(userId, deviceAddress, priviledgedPackage)));
}
private void updateAssociations(Function<List<Association>, List<Association>> update) {
updateAssociations(update, getCallingUserId());
}
private void updateAssociations(Function<List<Association>, List<Association>> update,
int userId) {
final AtomicFile file = getStorageFileForUser(userId);
synchronized (file) {
List<Association> associations = readAllAssociations(userId);
final List<Association> old = CollectionUtils.copyOf(associations);
associations = update.apply(associations);
if (size(old) == size(associations)) return;
List<Association> finalAssociations = associations;
file.write((out) -> {
XmlSerializer xml = Xml.newSerializer();
try {
xml.setOutput(out, StandardCharsets.UTF_8.name());
xml.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true);
xml.startDocument(null, true);
xml.startTag(null, XML_TAG_ASSOCIATIONS);
for (int i = 0; i < size(finalAssociations); i++) {
Association association = finalAssociations.get(i);
xml.startTag(null, XML_TAG_ASSOCIATION)
.attribute(null, XML_ATTR_PACKAGE, association.companionAppPackage)
.attribute(null, XML_ATTR_DEVICE, association.deviceAddress)
.endTag(null, XML_TAG_ASSOCIATION);
}
xml.endTag(null, XML_TAG_ASSOCIATIONS);
xml.endDocument();
} catch (Exception e) {
Slog.e(LOG_TAG, "Error while writing associations file", e);
throw ExceptionUtils.propagate(e);
}
});
}
}
private AtomicFile getStorageFileForUser(int uid) {
return mUidToStorage.computeIfAbsent(uid, (u) ->
new AtomicFile(new File(
//TODO deprecated method - what's the right replacement?
Environment.getUserSystemDirectory(u),
XML_FILE_NAME)));
}
@Nullable
private ArrayList<Association> readAllAssociations(int userId) {
return readAllAssociations(userId, null);
}
@Nullable
private ArrayList<Association> readAllAssociations(int userId, @Nullable String packageFilter) {
final AtomicFile file = getStorageFileForUser(userId);
if (!file.getBaseFile().exists()) return null;
ArrayList<Association> result = null;
final XmlPullParser parser = Xml.newPullParser();
synchronized (file) {
try (FileInputStream in = file.openRead()) {
parser.setInput(in, StandardCharsets.UTF_8.name());
int type;
while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) {
if (type != XmlPullParser.START_TAG
&& !XML_TAG_ASSOCIATIONS.equals(parser.getName())) continue;
final String appPackage = parser.getAttributeValue(null, XML_ATTR_PACKAGE);
final String deviceAddress = parser.getAttributeValue(null, XML_ATTR_DEVICE);
if (appPackage == null || deviceAddress == null) continue;
if (packageFilter != null && !packageFilter.equals(appPackage)) continue;
result = ArrayUtils.add(result,
new Association(userId, deviceAddress, appPackage));
}
return result;
} catch (XmlPullParserException | IOException e) {
Slog.e(LOG_TAG, "Error while reading associations file", e);
return null;
}
}
}
private class Association {
public final int uid;
public final String deviceAddress;
public final String companionAppPackage;
private Association(int uid, String deviceAddress, String companionAppPackage) {
this.uid = uid;
this.deviceAddress = checkNotNull(deviceAddress);
this.companionAppPackage = checkNotNull(companionAppPackage);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Association that = (Association) o;
if (uid != that.uid) return false;
if (!deviceAddress.equals(that.deviceAddress)) return false;
return companionAppPackage.equals(that.companionAppPackage);
}
@Override
public int hashCode() {
int result = uid;
result = 31 * result + deviceAddress.hashCode();
result = 31 * result + companionAppPackage.hashCode();
return result;
}
}
private class ShellCmd extends ShellCommand {
public static final String USAGE = "help\n"
+ "list USER_ID\n"
+ "associate USER_ID PACKAGE MAC_ADDRESS\n"
+ "disassociate USER_ID PACKAGE MAC_ADDRESS";
ShellCmd() {
getContext().enforceCallingOrSelfPermission(
android.Manifest.permission.MANAGE_COMPANION_DEVICES, "ShellCmd");
}
@Override
public int onCommand(String cmd) {
switch (cmd) {
case "list": {
ArrayList<Association> associations = readAllAssociations(getNextArgInt());
for (int i = 0; i < size(associations); i++) {
Association a = associations.get(i);
getOutPrintWriter()
.println(a.companionAppPackage + " " + a.deviceAddress);
}
} break;
case "associate": {
addAssociation(getNextArgInt(), getNextArgRequired(), getNextArgRequired());
} break;
case "disassociate": {
removeAssociation(getNextArgInt(), getNextArgRequired(), getNextArgRequired());
} break;
default: return handleDefaultCommands(cmd);
}
return 0;
}
private int getNextArgInt() {
return Integer.parseInt(getNextArgRequired());
}
@Override
public void onHelp() {
getOutPrintWriter().println(USAGE);
}
}
}