/* * 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 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 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 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 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> update) { updateAssociations(update, getCallingUserId()); } private void updateAssociations(Function, List> update, int userId) { final AtomicFile file = getStorageFileForUser(userId); synchronized (file) { List associations = readAllAssociations(userId); final List old = CollectionUtils.copyOf(associations); associations = update.apply(associations); if (size(old) == size(associations)) return; List 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 readAllAssociations(int userId) { return readAllAssociations(userId, null); } @Nullable private ArrayList readAllAssociations(int userId, @Nullable String packageFilter) { final AtomicFile file = getStorageFileForUser(userId); if (!file.getBaseFile().exists()) return null; ArrayList 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"; @Override public int onCommand(String cmd) { switch (cmd) { case "list": { ArrayList 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); } } }