OSDN Git Service

RESTRICT AUTOMERGE
[android-x86/frameworks-base.git] / services / companion / java / com / android / server / companion / CompanionDeviceManagerService.java
1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16
17
18 package com.android.server.companion;
19
20 import static com.android.internal.util.CollectionUtils.size;
21 import static com.android.internal.util.Preconditions.checkArgument;
22 import static com.android.internal.util.Preconditions.checkNotNull;
23 import static com.android.internal.util.Preconditions.checkState;
24 import static com.android.internal.util.function.pooled.PooledLambda.obtainRunnable;
25
26 import android.Manifest;
27 import android.annotation.CheckResult;
28 import android.annotation.Nullable;
29 import android.app.PendingIntent;
30 import android.companion.AssociationRequest;
31 import android.companion.CompanionDeviceManager;
32 import android.companion.ICompanionDeviceDiscoveryService;
33 import android.companion.ICompanionDeviceDiscoveryServiceCallback;
34 import android.companion.ICompanionDeviceManager;
35 import android.companion.IFindDeviceCallback;
36 import android.content.ComponentName;
37 import android.content.Context;
38 import android.content.Intent;
39 import android.content.ServiceConnection;
40 import android.content.pm.FeatureInfo;
41 import android.content.pm.PackageInfo;
42 import android.content.pm.PackageManager;
43 import android.net.NetworkPolicyManager;
44 import android.os.Binder;
45 import android.os.Environment;
46 import android.os.Handler;
47 import android.os.IBinder;
48 import android.os.IDeviceIdleController;
49 import android.os.IInterface;
50 import android.os.Parcel;
51 import android.os.Process;
52 import android.os.RemoteException;
53 import android.os.ResultReceiver;
54 import android.os.ServiceManager;
55 import android.os.ShellCallback;
56 import android.os.ShellCommand;
57 import android.os.UserHandle;
58 import android.provider.Settings;
59 import android.provider.SettingsStringUtil.ComponentNameSet;
60 import android.text.BidiFormatter;
61 import android.util.ArraySet;
62 import android.util.AtomicFile;
63 import android.util.ExceptionUtils;
64 import android.util.Log;
65 import android.util.Slog;
66 import android.util.Xml;
67
68 import com.android.internal.app.IAppOpsService;
69 import com.android.internal.content.PackageMonitor;
70 import com.android.internal.notification.NotificationAccessConfirmationActivityContract;
71 import com.android.internal.util.ArrayUtils;
72 import com.android.internal.util.CollectionUtils;
73 import com.android.internal.util.function.pooled.PooledLambda;
74 import com.android.server.FgThread;
75 import com.android.server.SystemService;
76
77 import org.xmlpull.v1.XmlPullParser;
78 import org.xmlpull.v1.XmlPullParserException;
79 import org.xmlpull.v1.XmlSerializer;
80
81 import java.io.File;
82 import java.io.FileDescriptor;
83 import java.io.FileInputStream;
84 import java.io.IOException;
85 import java.nio.charset.StandardCharsets;
86 import java.util.ArrayList;
87 import java.util.List;
88 import java.util.Objects;
89 import java.util.Set;
90 import java.util.concurrent.ConcurrentHashMap;
91 import java.util.concurrent.ConcurrentMap;
92 import java.util.function.Function;
93
94 //TODO onStop schedule unbind in 5 seconds
95 //TODO make sure APIs are only callable from currently focused app
96 //TODO schedule stopScan on activity destroy(except if configuration change)
97 //TODO on associate called again after configuration change -> replace old callback with new
98 //TODO avoid leaking calling activity in IFindDeviceCallback (see PrintManager#print for example)
99 /** @hide */
100 public class CompanionDeviceManagerService extends SystemService implements Binder.DeathRecipient {
101
102     private static final ComponentName SERVICE_TO_BIND_TO = ComponentName.createRelative(
103             CompanionDeviceManager.COMPANION_DEVICE_DISCOVERY_PACKAGE_NAME,
104             ".DeviceDiscoveryService");
105
106     private static final boolean DEBUG = false;
107     private static final String LOG_TAG = "CompanionDeviceManagerService";
108
109     private static final String XML_TAG_ASSOCIATIONS = "associations";
110     private static final String XML_TAG_ASSOCIATION = "association";
111     private static final String XML_ATTR_PACKAGE = "package";
112     private static final String XML_ATTR_DEVICE = "device";
113     private static final String XML_FILE_NAME = "companion_device_manager_associations.xml";
114
115     private final CompanionDeviceManagerImpl mImpl;
116     private final ConcurrentMap<Integer, AtomicFile> mUidToStorage = new ConcurrentHashMap<>();
117     private IDeviceIdleController mIdleController;
118     private ServiceConnection mServiceConnection;
119     private IAppOpsService mAppOpsManager;
120
121     private IFindDeviceCallback mFindDeviceCallback;
122     private AssociationRequest mRequest;
123     private String mCallingPackage;
124
125     private final Object mLock = new Object();
126
127     public CompanionDeviceManagerService(Context context) {
128         super(context);
129         mImpl = new CompanionDeviceManagerImpl();
130         mIdleController = IDeviceIdleController.Stub.asInterface(
131                 ServiceManager.getService(Context.DEVICE_IDLE_CONTROLLER));
132         mAppOpsManager = IAppOpsService.Stub.asInterface(
133                 ServiceManager.getService(Context.APP_OPS_SERVICE));
134         registerPackageMonitor();
135     }
136
137     private void registerPackageMonitor() {
138         new PackageMonitor() {
139             @Override
140             public void onPackageRemoved(String packageName, int uid) {
141                 updateAssociations(
142                         as -> CollectionUtils.filter(as,
143                                 a -> !Objects.equals(a.companionAppPackage, packageName)),
144                         getChangingUserId());
145             }
146
147             @Override
148             public void onPackageModified(String packageName) {
149                 int userId = getChangingUserId();
150                 if (!ArrayUtils.isEmpty(readAllAssociations(userId, packageName))) {
151                     updateSpecialAccessPermissionForAssociatedPackage(packageName, userId);
152                 }
153             }
154
155         }.register(getContext(), FgThread.get().getLooper(), UserHandle.ALL, true);
156     }
157
158     @Override
159     public void onStart() {
160         publishBinderService(Context.COMPANION_DEVICE_SERVICE, mImpl);
161     }
162
163     @Override
164     public void binderDied() {
165         Handler.getMain().post(this::cleanup);
166     }
167
168     private void cleanup() {
169         synchronized (mLock) {
170             mServiceConnection = unbind(mServiceConnection);
171             mFindDeviceCallback = unlinkToDeath(mFindDeviceCallback, this, 0);
172             mRequest = null;
173             mCallingPackage = null;
174         }
175     }
176
177     /**
178      * Usage: {@code a = unlinkToDeath(a, deathRecipient, flags); }
179      */
180     @Nullable
181     @CheckResult
182     private static <T extends IInterface> T unlinkToDeath(T iinterface,
183             IBinder.DeathRecipient deathRecipient, int flags) {
184         if (iinterface != null) {
185             iinterface.asBinder().unlinkToDeath(deathRecipient, flags);
186         }
187         return null;
188     }
189
190     @Nullable
191     @CheckResult
192     private ServiceConnection unbind(@Nullable ServiceConnection conn) {
193         if (conn != null) {
194             getContext().unbindService(conn);
195         }
196         return null;
197     }
198
199     class CompanionDeviceManagerImpl extends ICompanionDeviceManager.Stub {
200
201         @Override
202         public boolean onTransact(int code, Parcel data, Parcel reply, int flags)
203                 throws RemoteException {
204             try {
205                 return super.onTransact(code, data, reply, flags);
206             } catch (Throwable e) {
207                 Slog.e(LOG_TAG, "Error during IPC", e);
208                 throw ExceptionUtils.propagate(e, RemoteException.class);
209             }
210         }
211
212         @Override
213         public void associate(
214                 AssociationRequest request,
215                 IFindDeviceCallback callback,
216                 String callingPackage) throws RemoteException {
217             if (DEBUG) {
218                 Slog.i(LOG_TAG, "associate(request = " + request + ", callback = " + callback
219                         + ", callingPackage = " + callingPackage + ")");
220             }
221             checkNotNull(request, "Request cannot be null");
222             checkNotNull(callback, "Callback cannot be null");
223             checkCallerIsSystemOr(callingPackage);
224             int userId = getCallingUserId();
225             checkUsesFeature(callingPackage, userId);
226             final long callingIdentity = Binder.clearCallingIdentity();
227             try {
228                 getContext().bindServiceAsUser(
229                         new Intent().setComponent(SERVICE_TO_BIND_TO),
230                         createServiceConnection(request, callback, callingPackage),
231                         Context.BIND_AUTO_CREATE,
232                         UserHandle.of(userId));
233             } finally {
234                 Binder.restoreCallingIdentity(callingIdentity);
235             }
236         }
237
238         @Override
239         public void stopScan(AssociationRequest request,
240                 IFindDeviceCallback callback,
241                 String callingPackage) {
242             if (Objects.equals(request, mRequest)
243                     && Objects.equals(callback, mFindDeviceCallback)
244                     && Objects.equals(callingPackage, mCallingPackage)) {
245                 cleanup();
246             }
247         }
248
249         @Override
250         public List<String> getAssociations(String callingPackage, int userId)
251                 throws RemoteException {
252             checkCallerIsSystemOr(callingPackage, userId);
253             checkUsesFeature(callingPackage, getCallingUserId());
254             return new ArrayList<>(CollectionUtils.map(
255                     readAllAssociations(userId, callingPackage),
256                     a -> a.deviceAddress));
257         }
258
259         //TODO also revoke notification access
260         @Override
261         public void disassociate(String deviceMacAddress, String callingPackage)
262                 throws RemoteException {
263             checkNotNull(deviceMacAddress);
264             checkCallerIsSystemOr(callingPackage);
265             checkUsesFeature(callingPackage, getCallingUserId());
266             removeAssociation(getCallingUserId(), callingPackage, deviceMacAddress);
267         }
268
269         private void checkCallerIsSystemOr(String pkg) throws RemoteException {
270             checkCallerIsSystemOr(pkg, getCallingUserId());
271         }
272
273         private void checkCallerIsSystemOr(String pkg, int userId) throws RemoteException {
274             if (isCallerSystem()) {
275                 return;
276             }
277
278             checkArgument(getCallingUserId() == userId,
279                     "Must be called by either same user or system");
280             mAppOpsManager.checkPackage(Binder.getCallingUid(), pkg);
281         }
282
283         @Override
284         public PendingIntent requestNotificationAccess(ComponentName component)
285                 throws RemoteException {
286             String callingPackage = component.getPackageName();
287             checkCanCallNotificationApi(callingPackage);
288             int userId = getCallingUserId();
289             String packageTitle = BidiFormatter.getInstance().unicodeWrap(
290                     getPackageInfo(callingPackage, userId)
291                             .applicationInfo
292                             .loadSafeLabel(getContext().getPackageManager())
293                             .toString());
294             long identity = Binder.clearCallingIdentity();
295             try {
296                 return PendingIntent.getActivity(getContext(),
297                         0 /* request code */,
298                         NotificationAccessConfirmationActivityContract.launcherIntent(
299                                 userId, component, packageTitle),
300                         PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT
301                                 | PendingIntent.FLAG_CANCEL_CURRENT);
302             } finally {
303                 Binder.restoreCallingIdentity(identity);
304             }
305         }
306
307         @Override
308         public boolean hasNotificationAccess(ComponentName component) throws RemoteException {
309             checkCanCallNotificationApi(component.getPackageName());
310             String setting = Settings.Secure.getString(getContext().getContentResolver(),
311                     Settings.Secure.ENABLED_NOTIFICATION_LISTENERS);
312             return new ComponentNameSet(setting).contains(component);
313         }
314
315         private void checkCanCallNotificationApi(String callingPackage) throws RemoteException {
316             checkCallerIsSystemOr(callingPackage);
317             int userId = getCallingUserId();
318             checkState(!ArrayUtils.isEmpty(readAllAssociations(userId, callingPackage)),
319                     "App must have an association before calling this API");
320             checkUsesFeature(callingPackage, userId);
321         }
322
323         private void checkUsesFeature(String pkg, int userId) {
324             if (isCallerSystem()) {
325                 // Drop the requirement for calls from system process
326                 return;
327             }
328
329             FeatureInfo[] reqFeatures = getPackageInfo(pkg, userId).reqFeatures;
330             String requiredFeature = PackageManager.FEATURE_COMPANION_DEVICE_SETUP;
331             int numFeatures = ArrayUtils.size(reqFeatures);
332             for (int i = 0; i < numFeatures; i++) {
333                 if (requiredFeature.equals(reqFeatures[i].name)) return;
334             }
335             throw new IllegalStateException("Must declare uses-feature "
336                     + requiredFeature
337                     + " in manifest to use this API");
338         }
339
340         @Override
341         public void onShellCommand(FileDescriptor in, FileDescriptor out, FileDescriptor err,
342                 String[] args, ShellCallback callback, ResultReceiver resultReceiver)
343                 throws RemoteException {
344             new ShellCmd().exec(this, in, out, err, args, callback, resultReceiver);
345         }
346     }
347
348     private static int getCallingUserId() {
349         return UserHandle.getUserId(Binder.getCallingUid());
350     }
351
352     private static boolean isCallerSystem() {
353         return Binder.getCallingUid() == Process.SYSTEM_UID;
354     }
355
356     private ServiceConnection createServiceConnection(
357             final AssociationRequest request,
358             final IFindDeviceCallback findDeviceCallback,
359             final String callingPackage) {
360         mServiceConnection = new ServiceConnection() {
361             @Override
362             public void onServiceConnected(ComponentName name, IBinder service) {
363                 if (DEBUG) {
364                     Slog.i(LOG_TAG,
365                             "onServiceConnected(name = " + name + ", service = "
366                                     + service + ")");
367                 }
368
369                 mFindDeviceCallback = findDeviceCallback;
370                 mRequest = request;
371                 mCallingPackage = callingPackage;
372
373                 try {
374                     mFindDeviceCallback.asBinder().linkToDeath(
375                             CompanionDeviceManagerService.this, 0);
376                 } catch (RemoteException e) {
377                     cleanup();
378                     return;
379                 }
380
381                 try {
382                     ICompanionDeviceDiscoveryService.Stub
383                             .asInterface(service)
384                             .startDiscovery(
385                                     request,
386                                     callingPackage,
387                                     findDeviceCallback,
388                                     getServiceCallback());
389                 } catch (RemoteException e) {
390                     Log.e(LOG_TAG, "Error while initiating device discovery", e);
391                 }
392             }
393
394             @Override
395             public void onServiceDisconnected(ComponentName name) {
396                 if (DEBUG) Slog.i(LOG_TAG, "onServiceDisconnected(name = " + name + ")");
397             }
398         };
399         return mServiceConnection;
400     }
401
402     private ICompanionDeviceDiscoveryServiceCallback.Stub getServiceCallback() {
403         return new ICompanionDeviceDiscoveryServiceCallback.Stub() {
404
405             @Override
406             public boolean onTransact(int code, Parcel data, Parcel reply, int flags)
407                     throws RemoteException {
408                 try {
409                     return super.onTransact(code, data, reply, flags);
410                 } catch (Throwable e) {
411                     Slog.e(LOG_TAG, "Error during IPC", e);
412                     throw ExceptionUtils.propagate(e, RemoteException.class);
413                 }
414             }
415
416             @Override
417             public void onDeviceSelected(String packageName, int userId, String deviceAddress) {
418                 addAssociation(userId, packageName, deviceAddress);
419                 cleanup();
420             }
421
422             @Override
423             public void onDeviceSelectionCancel() {
424                 cleanup();
425             }
426         };
427     }
428
429     void addAssociation(int userId, String packageName, String deviceAddress) {
430         updateSpecialAccessPermissionForAssociatedPackage(packageName, userId);
431         recordAssociation(packageName, deviceAddress);
432     }
433
434     void removeAssociation(int userId, String pkg, String deviceMacAddress) {
435         updateAssociations(associations -> CollectionUtils.remove(associations,
436                 new Association(userId, deviceMacAddress, pkg)));
437     }
438
439     private void updateSpecialAccessPermissionForAssociatedPackage(String packageName, int userId) {
440         PackageInfo packageInfo = getPackageInfo(packageName, userId);
441         if (packageInfo == null) {
442             return;
443         }
444
445         Binder.withCleanCallingIdentity(obtainRunnable(CompanionDeviceManagerService::
446                 updateSpecialAccessPermissionAsSystem, this, packageInfo).recycleOnUse());
447     }
448
449     private void updateSpecialAccessPermissionAsSystem(PackageInfo packageInfo) {
450         try {
451             if (containsEither(packageInfo.requestedPermissions,
452                     android.Manifest.permission.RUN_IN_BACKGROUND,
453                     android.Manifest.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND)) {
454                 mIdleController.addPowerSaveWhitelistApp(packageInfo.packageName);
455             } else {
456                 mIdleController.removePowerSaveWhitelistApp(packageInfo.packageName);
457             }
458         } catch (RemoteException e) {
459             /* ignore - local call */
460         }
461
462         NetworkPolicyManager networkPolicyManager = NetworkPolicyManager.from(getContext());
463         if (containsEither(packageInfo.requestedPermissions,
464                 android.Manifest.permission.USE_DATA_IN_BACKGROUND,
465                 android.Manifest.permission.REQUEST_COMPANION_USE_DATA_IN_BACKGROUND)) {
466             networkPolicyManager.addUidPolicy(
467                     packageInfo.applicationInfo.uid,
468                     NetworkPolicyManager.POLICY_ALLOW_METERED_BACKGROUND);
469         } else {
470             networkPolicyManager.removeUidPolicy(
471                     packageInfo.applicationInfo.uid,
472                     NetworkPolicyManager.POLICY_ALLOW_METERED_BACKGROUND);
473         }
474     }
475
476     private static <T> boolean containsEither(T[] array, T a, T b) {
477         return ArrayUtils.contains(array, a) || ArrayUtils.contains(array, b);
478     }
479
480     @Nullable
481     private PackageInfo getPackageInfo(String packageName, int userId) {
482         return Binder.withCleanCallingIdentity(PooledLambda.obtainSupplier((context, pkg, id) -> {
483             try {
484                 return context.getPackageManager().getPackageInfoAsUser(
485                         pkg,
486                         PackageManager.GET_PERMISSIONS | PackageManager.GET_CONFIGURATIONS,
487                         id);
488             } catch (PackageManager.NameNotFoundException e) {
489                 Slog.e(LOG_TAG, "Failed to get PackageInfo for package " + pkg, e);
490                 return null;
491             }
492         }, getContext(), packageName, userId).recycleOnUse());
493     }
494
495     private void recordAssociation(String priviledgedPackage, String deviceAddress) {
496         if (DEBUG) {
497             Log.i(LOG_TAG, "recordAssociation(priviledgedPackage = " + priviledgedPackage
498                     + ", deviceAddress = " + deviceAddress + ")");
499         }
500         int userId = getCallingUserId();
501         updateAssociations(associations -> CollectionUtils.add(associations,
502                 new Association(userId, deviceAddress, priviledgedPackage)));
503     }
504
505     private void updateAssociations(Function<Set<Association>, Set<Association>> update) {
506         updateAssociations(update, getCallingUserId());
507     }
508
509     private void updateAssociations(Function<Set<Association>, Set<Association>> update,
510             int userId) {
511         final AtomicFile file = getStorageFileForUser(userId);
512         synchronized (file) {
513             Set<Association> associations = readAllAssociations(userId);
514             final Set<Association> old = CollectionUtils.copyOf(associations);
515             associations = update.apply(associations);
516             if (size(old) == size(associations)) return;
517
518             Set<Association> finalAssociations = associations;
519             file.write((out) -> {
520                 XmlSerializer xml = Xml.newSerializer();
521                 try {
522                     xml.setOutput(out, StandardCharsets.UTF_8.name());
523                     xml.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true);
524                     xml.startDocument(null, true);
525                     xml.startTag(null, XML_TAG_ASSOCIATIONS);
526
527                     CollectionUtils.forEach(finalAssociations, association -> {
528                         xml.startTag(null, XML_TAG_ASSOCIATION)
529                                 .attribute(null, XML_ATTR_PACKAGE, association.companionAppPackage)
530                                 .attribute(null, XML_ATTR_DEVICE, association.deviceAddress)
531                                 .endTag(null, XML_TAG_ASSOCIATION);
532                     });
533
534                     xml.endTag(null, XML_TAG_ASSOCIATIONS);
535                     xml.endDocument();
536                 } catch (Exception e) {
537                     Slog.e(LOG_TAG, "Error while writing associations file", e);
538                     throw ExceptionUtils.propagate(e);
539                 }
540
541             });
542         }
543     }
544
545     private AtomicFile getStorageFileForUser(int uid) {
546         return mUidToStorage.computeIfAbsent(uid, (u) ->
547                 new AtomicFile(new File(
548                         //TODO deprecated method - what's the right replacement?
549                         Environment.getUserSystemDirectory(u),
550                         XML_FILE_NAME)));
551     }
552
553     @Nullable
554     private Set<Association> readAllAssociations(int userId) {
555         return readAllAssociations(userId, null);
556     }
557
558     @Nullable
559     private Set<Association> readAllAssociations(int userId, @Nullable String packageFilter) {
560         final AtomicFile file = getStorageFileForUser(userId);
561
562         if (!file.getBaseFile().exists()) return null;
563
564         ArraySet<Association> result = null;
565         final XmlPullParser parser = Xml.newPullParser();
566         synchronized (file) {
567             try (FileInputStream in = file.openRead()) {
568                 parser.setInput(in, StandardCharsets.UTF_8.name());
569                 int type;
570                 while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) {
571                     if (type != XmlPullParser.START_TAG
572                             && !XML_TAG_ASSOCIATIONS.equals(parser.getName())) continue;
573
574                     final String appPackage = parser.getAttributeValue(null, XML_ATTR_PACKAGE);
575                     final String deviceAddress = parser.getAttributeValue(null, XML_ATTR_DEVICE);
576
577                     if (appPackage == null || deviceAddress == null) continue;
578                     if (packageFilter != null && !packageFilter.equals(appPackage)) continue;
579
580                     result = ArrayUtils.add(result,
581                             new Association(userId, deviceAddress, appPackage));
582                 }
583                 return result;
584             } catch (XmlPullParserException | IOException e) {
585                 Slog.e(LOG_TAG, "Error while reading associations file", e);
586                 return null;
587             }
588         }
589     }
590
591
592
593     private class Association {
594         public final int uid;
595         public final String deviceAddress;
596         public final String companionAppPackage;
597
598         private Association(int uid, String deviceAddress, String companionAppPackage) {
599             this.uid = uid;
600             this.deviceAddress = checkNotNull(deviceAddress);
601             this.companionAppPackage = checkNotNull(companionAppPackage);
602         }
603
604         @Override
605         public boolean equals(Object o) {
606             if (this == o) return true;
607             if (o == null || getClass() != o.getClass()) return false;
608
609             Association that = (Association) o;
610
611             if (uid != that.uid) return false;
612             if (!deviceAddress.equals(that.deviceAddress)) return false;
613             return companionAppPackage.equals(that.companionAppPackage);
614
615         }
616
617         @Override
618         public int hashCode() {
619             int result = uid;
620             result = 31 * result + deviceAddress.hashCode();
621             result = 31 * result + companionAppPackage.hashCode();
622             return result;
623         }
624     }
625
626     private class ShellCmd extends ShellCommand {
627         public static final String USAGE = "help\n"
628                 + "list USER_ID\n"
629                 + "associate USER_ID PACKAGE MAC_ADDRESS\n"
630                 + "disassociate USER_ID PACKAGE MAC_ADDRESS";
631
632         ShellCmd() {
633             getContext().enforceCallingOrSelfPermission(
634                     android.Manifest.permission.MANAGE_COMPANION_DEVICES, "ShellCmd");
635         }
636
637         @Override
638         public int onCommand(String cmd) {
639             switch (cmd) {
640                 case "list": {
641                     CollectionUtils.forEach(
642                             readAllAssociations(getNextArgInt()),
643                             a -> getOutPrintWriter()
644                                     .println(a.companionAppPackage + " " + a.deviceAddress));
645                 } break;
646
647                 case "associate": {
648                     addAssociation(getNextArgInt(), getNextArgRequired(), getNextArgRequired());
649                 } break;
650
651                 case "disassociate": {
652                     removeAssociation(getNextArgInt(), getNextArgRequired(), getNextArgRequired());
653                 } break;
654
655                 default: return handleDefaultCommands(cmd);
656             }
657             return 0;
658         }
659
660         private int getNextArgInt() {
661             return Integer.parseInt(getNextArgRequired());
662         }
663
664         @Override
665         public void onHelp() {
666             getOutPrintWriter().println(USAGE);
667         }
668     }
669
670 }