Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 767ac317 authored by Hugo Benichi's avatar Hugo Benichi Committed by Gerrit Code Review
Browse files

Merge changes I4872f8ba,I92039f29,Iaad13e13

* changes:
  NsdService: simple cleanups
  NsdService: test coverage for client requests.
  NsdManager: remove duplicated argument validation
parents 1101f456 0f86b448
Loading
Loading
Loading
Loading
+81 −103
Original line number Diff line number Diff line
@@ -16,6 +16,10 @@

package android.net.nsd;

import static com.android.internal.util.Preconditions.checkArgument;
import static com.android.internal.util.Preconditions.checkNotNull;
import static com.android.internal.util.Preconditions.checkStringNotEmpty;

import android.annotation.SdkConstant;
import android.annotation.SdkConstant.SdkConstantType;
import android.content.Context;
@@ -241,12 +245,12 @@ public final class NsdManager {
        return name;
    }

    private static int FIRST_LISTENER_KEY = 1;

    private final INsdManager mService;
    private final Context mContext;

    private static final int INVALID_LISTENER_KEY = 0;
    private static final int BUSY_LISTENER_KEY = -1;
    private int mListenerKey = 1;
    private int mListenerKey = FIRST_LISTENER_KEY;
    private final SparseArray mListenerMap = new SparseArray();
    private final SparseArray<NsdServiceInfo> mServiceMap = new SparseArray<>();
    private final Object mMapLock = new Object();
@@ -269,6 +273,14 @@ public final class NsdManager {
        init();
    }

    /**
     * @hide
     */
    @VisibleForTesting
    public void disconnect() {
        mAsyncChannel.disconnect();
    }

    /**
     * Failures are passed with {@link RegistrationListener#onRegistrationFailed},
     * {@link RegistrationListener#onUnregistrationFailed},
@@ -304,7 +316,6 @@ public final class NsdManager {
        public void onServiceFound(NsdServiceInfo serviceInfo);

        public void onServiceLost(NsdServiceInfo serviceInfo);

    }

    /** Interface for callback invocation for service registration */
@@ -335,8 +346,9 @@ public final class NsdManager {

        @Override
        public void handleMessage(Message message) {
            if (DBG) Log.d(TAG, "received " + nameOf(message.what));
            switch (message.what) {
            final int what = message.what;
            final int key = message.arg2;
            switch (what) {
                case AsyncChannel.CMD_CHANNEL_HALF_CONNECTED:
                    mAsyncChannel.sendMessage(AsyncChannel.CMD_CHANNEL_FULL_CONNECTION);
                    return;
@@ -349,19 +361,26 @@ public final class NsdManager {
                default:
                    break;
            }
            Object listener = getListener(message.arg2);
            final Object listener;
            final NsdServiceInfo ns;
            synchronized (mMapLock) {
                listener = mListenerMap.get(key);
                ns = mServiceMap.get(key);
            }
            if (listener == null) {
                Log.d(TAG, "Stale key " + message.arg2);
                return;
            }
            NsdServiceInfo ns = getNsdService(message.arg2);
            switch (message.what) {
            if (DBG) {
                Log.d(TAG, "received " + nameOf(what) + " for key " + key + ", service " + ns);
            }
            switch (what) {
                case DISCOVER_SERVICES_STARTED:
                    String s = getNsdServiceInfoType((NsdServiceInfo) message.obj);
                    ((DiscoveryListener) listener).onDiscoveryStarted(s);
                    break;
                case DISCOVER_SERVICES_FAILED:
                    removeListener(message.arg2);
                    removeListener(key);
                    ((DiscoveryListener) listener).onStartDiscoveryFailed(getNsdServiceInfoType(ns),
                            message.arg1);
                    break;
@@ -374,16 +393,16 @@ public final class NsdManager {
                case STOP_DISCOVERY_FAILED:
                    // TODO: failure to stop discovery should be internal and retried internally, as
                    // the effect for the client is indistinguishable from STOP_DISCOVERY_SUCCEEDED
                    removeListener(message.arg2);
                    removeListener(key);
                    ((DiscoveryListener) listener).onStopDiscoveryFailed(getNsdServiceInfoType(ns),
                            message.arg1);
                    break;
                case STOP_DISCOVERY_SUCCEEDED:
                    removeListener(message.arg2);
                    removeListener(key);
                    ((DiscoveryListener) listener).onDiscoveryStopped(getNsdServiceInfoType(ns));
                    break;
                case REGISTER_SERVICE_FAILED:
                    removeListener(message.arg2);
                    removeListener(key);
                    ((RegistrationListener) listener).onRegistrationFailed(ns, message.arg1);
                    break;
                case REGISTER_SERVICE_SUCCEEDED:
@@ -391,7 +410,7 @@ public final class NsdManager {
                            (NsdServiceInfo) message.obj);
                    break;
                case UNREGISTER_SERVICE_FAILED:
                    removeListener(message.arg2);
                    removeListener(key);
                    ((RegistrationListener) listener).onUnregistrationFailed(ns, message.arg1);
                    break;
                case UNREGISTER_SERVICE_SUCCEEDED:
@@ -401,11 +420,11 @@ public final class NsdManager {
                    ((RegistrationListener) listener).onServiceUnregistered(ns);
                    break;
                case RESOLVE_SERVICE_FAILED:
                    removeListener(message.arg2);
                    removeListener(key);
                    ((ResolveListener) listener).onResolveFailed(ns, message.arg1);
                    break;
                case RESOLVE_SERVICE_SUCCEEDED:
                    removeListener(message.arg2);
                    removeListener(key);
                    ((ResolveListener) listener).onServiceResolved((NsdServiceInfo) message.obj);
                    break;
                default:
@@ -415,40 +434,27 @@ public final class NsdManager {
        }
    }

    // if the listener is already in the map, reject it.  Otherwise, add it and
    // return its key.
    private int nextListenerKey() {
        // Ensure mListenerKey >= FIRST_LISTENER_KEY;
        mListenerKey = Math.max(FIRST_LISTENER_KEY, mListenerKey + 1);
        return mListenerKey;
    }

    // Assert that the listener is not in the map, then add it and returns its key
    private int putListener(Object listener, NsdServiceInfo s) {
        if (listener == null) return INVALID_LISTENER_KEY;
        int key;
        checkListener(listener);
        final int key;
        synchronized (mMapLock) {
            int valueIndex = mListenerMap.indexOfValue(listener);
            if (valueIndex != -1) {
                return BUSY_LISTENER_KEY;
            }
            do {
                key = mListenerKey++;
            } while (key == INVALID_LISTENER_KEY);
            checkArgument(valueIndex == -1, "listener already in use");
            key = nextListenerKey();
            mListenerMap.put(key, listener);
            mServiceMap.put(key, s);
        }
        return key;
    }

    private Object getListener(int key) {
        if (key == INVALID_LISTENER_KEY) return null;
        synchronized (mMapLock) {
            return mListenerMap.get(key);
        }
    }

    private NsdServiceInfo getNsdService(int key) {
        synchronized (mMapLock) {
            return mServiceMap.get(key);
        }
    }

    private void removeListener(int key) {
        if (key == INVALID_LISTENER_KEY) return;
        synchronized (mMapLock) {
            mListenerMap.remove(key);
            mServiceMap.remove(key);
@@ -456,16 +462,15 @@ public final class NsdManager {
    }

    private int getListenerKey(Object listener) {
        checkListener(listener);
        synchronized (mMapLock) {
            int valueIndex = mListenerMap.indexOfValue(listener);
            if (valueIndex != -1) {
            checkArgument(valueIndex != -1, "listener not registered");
            return mListenerMap.keyAt(valueIndex);
        }
    }
        return INVALID_LISTENER_KEY;
    }

    private String getNsdServiceInfoType(NsdServiceInfo s) {
    private static String getNsdServiceInfoType(NsdServiceInfo s) {
        if (s == null) return "?";
        return s.getServiceType();
    }
@@ -475,7 +480,9 @@ public final class NsdManager {
     */
    private void init() {
        final Messenger messenger = getMessenger();
        if (messenger == null) throw new RuntimeException("Failed to initialize");
        if (messenger == null) {
            fatal("Failed to obtain service Messenger");
        }
        HandlerThread t = new HandlerThread("NsdManager");
        t.start();
        mHandler = new ServiceHandler(t.getLooper());
@@ -483,8 +490,13 @@ public final class NsdManager {
        try {
            mConnected.await();
        } catch (InterruptedException e) {
            Log.e(TAG, "interrupted wait at init");
            fatal("Interrupted wait at init");
        }
    }

    private static void fatal(String msg) {
        Log.e(TAG, msg);
        throw new RuntimeException(msg);
    }

    /**
@@ -506,23 +518,10 @@ public final class NsdManager {
     */
    public void registerService(NsdServiceInfo serviceInfo, int protocolType,
            RegistrationListener listener) {
        if (TextUtils.isEmpty(serviceInfo.getServiceName()) ||
                TextUtils.isEmpty(serviceInfo.getServiceType())) {
            throw new IllegalArgumentException("Service name or type cannot be empty");
        }
        if (serviceInfo.getPort() <= 0) {
            throw new IllegalArgumentException("Invalid port number");
        }
        if (listener == null) {
            throw new IllegalArgumentException("listener cannot be null");
        }
        if (protocolType != PROTOCOL_DNS_SD) {
            throw new IllegalArgumentException("Unsupported protocol");
        }
        checkArgument(serviceInfo.getPort() > 0, "Invalid port number");
        checkServiceInfo(serviceInfo);
        checkProtocol(protocolType);
        int key = putListener(listener, serviceInfo);
        if (key == BUSY_LISTENER_KEY) {
            throw new IllegalArgumentException("listener already in use");
        }
        mAsyncChannel.sendMessage(REGISTER_SERVICE, 0, key, serviceInfo);
    }

@@ -541,12 +540,6 @@ public final class NsdManager {
     */
    public void unregisterService(RegistrationListener listener) {
        int id = getListenerKey(listener);
        if (id == INVALID_LISTENER_KEY) {
            throw new IllegalArgumentException("listener not registered");
        }
        if (listener == null) {
            throw new IllegalArgumentException("listener cannot be null");
        }
        mAsyncChannel.sendMessage(UNREGISTER_SERVICE, 0, id);
    }

@@ -579,25 +572,13 @@ public final class NsdManager {
     * Cannot be null. Cannot be in use for an active service discovery.
     */
    public void discoverServices(String serviceType, int protocolType, DiscoveryListener listener) {
        if (listener == null) {
            throw new IllegalArgumentException("listener cannot be null");
        }
        if (TextUtils.isEmpty(serviceType)) {
            throw new IllegalArgumentException("Service type cannot be empty");
        }

        if (protocolType != PROTOCOL_DNS_SD) {
            throw new IllegalArgumentException("Unsupported protocol");
        }
        checkStringNotEmpty(serviceType, "Service type cannot be empty");
        checkProtocol(protocolType);

        NsdServiceInfo s = new NsdServiceInfo();
        s.setServiceType(serviceType);

        int key = putListener(listener, s);
        if (key == BUSY_LISTENER_KEY) {
            throw new IllegalArgumentException("listener already in use");
        }

        mAsyncChannel.sendMessage(DISCOVER_SERVICES, 0, key, s);
    }

@@ -619,12 +600,6 @@ public final class NsdManager {
     */
    public void stopServiceDiscovery(DiscoveryListener listener) {
        int id = getListenerKey(listener);
        if (id == INVALID_LISTENER_KEY) {
            throw new IllegalArgumentException("service discovery not active on listener");
        }
        if (listener == null) {
            throw new IllegalArgumentException("listener cannot be null");
        }
        mAsyncChannel.sendMessage(STOP_DISCOVERY, 0, id);
    }

@@ -638,19 +613,8 @@ public final class NsdManager {
     * Cannot be in use for an active service resolution.
     */
    public void resolveService(NsdServiceInfo serviceInfo, ResolveListener listener) {
        if (TextUtils.isEmpty(serviceInfo.getServiceName()) ||
                TextUtils.isEmpty(serviceInfo.getServiceType())) {
            throw new IllegalArgumentException("Service name or type cannot be empty");
        }
        if (listener == null) {
            throw new IllegalArgumentException("listener cannot be null");
        }

        checkServiceInfo(serviceInfo);
        int key = putListener(listener, serviceInfo);

        if (key == BUSY_LISTENER_KEY) {
            throw new IllegalArgumentException("listener already in use");
        }
        mAsyncChannel.sendMessage(RESOLVE_SERVICE, 0, key, serviceInfo);
    }

@@ -664,10 +628,10 @@ public final class NsdManager {
    }

    /**
     * Get a reference to NetworkService handler. This is used to establish
     * Get a reference to NsdService handler. This is used to establish
     * an AsyncChannel communication with the service
     *
     * @return Messenger pointing to the NetworkService handler
     * @return Messenger pointing to the NsdService handler
     */
    private Messenger getMessenger() {
        try {
@@ -676,4 +640,18 @@ public final class NsdManager {
            throw e.rethrowFromSystemServer();
        }
    }

    private static void checkListener(Object listener) {
        checkNotNull(listener, "listener cannot be null");
    }

    private static void checkProtocol(int protocolType) {
        checkArgument(protocolType == PROTOCOL_DNS_SD, "Unsupported protocol");
    }

    private static void checkServiceInfo(NsdServiceInfo serviceInfo) {
        checkNotNull(serviceInfo, "NsdServiceInfo cannot be null");
        checkStringNotEmpty(serviceInfo.getServiceName(),"Service name cannot be empty");
        checkStringNotEmpty(serviceInfo.getServiceType(), "Service type cannot be empty");
    }
}
+22 −39
Original line number Diff line number Diff line
@@ -35,6 +35,7 @@ import android.provider.Settings;
import android.util.Base64;
import android.util.Slog;
import android.util.SparseArray;
import android.util.SparseIntArray;

import java.io.FileDescriptor;
import java.io.PrintWriter;
@@ -48,7 +49,6 @@ import com.android.internal.util.AsyncChannel;
import com.android.internal.util.Protocol;
import com.android.internal.util.State;
import com.android.internal.util.StateMachine;
import com.android.server.NativeDaemonConnector.Command;

/**
 * Network Service Discovery Service handles remote service discovery operation requests by
@@ -161,7 +161,7 @@ public class NsdService extends INsdManager.Stub {
                        }
                        //Last client
                        if (mClients.size() == 0) {
                            stopMDnsDaemon();
                            mDaemon.stop();
                        }
                        break;
                    case AsyncChannel.CMD_CHANNEL_FULL_CONNECTION:
@@ -221,14 +221,14 @@ public class NsdService extends INsdManager.Stub {
            public void enter() {
                sendNsdStateChangeBroadcast(true);
                if (mClients.size() > 0) {
                    startMDnsDaemon();
                    mDaemon.start();
                }
            }

            @Override
            public void exit() {
                if (mClients.size() > 0) {
                    stopMDnsDaemon();
                    mDaemon.stop();
                }
            }

@@ -247,8 +247,8 @@ public class NsdService extends INsdManager.Stub {
            }

            private void removeRequestMap(int clientId, int globalId, ClientInfo clientInfo) {
                clientInfo.mClientIds.remove(clientId);
                clientInfo.mClientRequests.remove(clientId);
                clientInfo.mClientIds.delete(clientId);
                clientInfo.mClientRequests.delete(clientId);
                mIdToClientInfoMap.remove(globalId);
            }

@@ -262,7 +262,7 @@ public class NsdService extends INsdManager.Stub {
                        //First client
                        if (msg.arg1 == AsyncChannel.STATUS_SUCCESSFUL &&
                                mClients.size() == 0) {
                            startMDnsDaemon();
                            mDaemon.start();
                        }
                        return NOT_HANDLED;
                    case AsyncChannel.CMD_CHANNEL_DISCONNECTED:
@@ -301,7 +301,7 @@ public class NsdService extends INsdManager.Stub {
                        clientInfo = mClients.get(msg.replyTo);

                        try {
                            id = clientInfo.mClientIds.get(msg.arg2).intValue();
                            id = clientInfo.mClientIds.get(msg.arg2);
                        } catch (NullPointerException e) {
                            replyToMessage(msg, NsdManager.STOP_DISCOVERY_FAILED,
                                    NsdManager.FAILURE_INTERNAL_ERROR);
@@ -339,7 +339,7 @@ public class NsdService extends INsdManager.Stub {
                        if (DBG) Slog.d(TAG, "unregister service");
                        clientInfo = mClients.get(msg.replyTo);
                        try {
                            id = clientInfo.mClientIds.get(msg.arg2).intValue();
                            id = clientInfo.mClientIds.get(msg.arg2);
                        } catch (NullPointerException e) {
                            replyToMessage(msg, NsdManager.UNREGISTER_SERVICE_FAILED,
                                    NsdManager.FAILURE_INTERNAL_ERROR);
@@ -712,26 +712,13 @@ public class NsdService extends INsdManager.Stub {
            return true;
        }

        public boolean execute(Command cmd) {
            if (DBG) {
                Slog.d(TAG, cmd.toString());
            }
            try {
                mNativeConnector.execute(cmd);
            } catch (NativeDaemonConnectorException e) {
                Slog.e(TAG, "Failed to execute " + cmd, e);
                return false;
            }
            return true;
        }
        public void start() {
            execute("start-service");
        }

    private boolean startMDnsDaemon() {
        return mDaemon.execute("start-service");
        public void stop() {
            execute("stop-service");
        }

    private boolean stopMDnsDaemon() {
        return mDaemon.execute("stop-service");
    }

    private boolean registerService(int regId, NsdServiceInfo service) {
@@ -743,8 +730,7 @@ public class NsdService extends INsdManager.Stub {
        int port = service.getPort();
        byte[] textRecord = service.getTxtRecord();
        String record = Base64.encodeToString(textRecord, Base64.DEFAULT).replace("\n", "");
        Command cmd = new Command("mdnssd", "register", regId, name, type, port, record);
        return mDaemon.execute(cmd);
        return mDaemon.execute("register", regId, name, type, port, record);
    }

    private boolean unregisterService(int regId) {
@@ -843,10 +829,10 @@ public class NsdService extends INsdManager.Stub {
        private NsdServiceInfo mResolvedService;

        /* A map from client id to unique id sent to mDns */
        private final SparseArray<Integer> mClientIds = new SparseArray<>();
        private final SparseIntArray mClientIds = new SparseIntArray();

        /* A map from client id to the type of the request we had received */
        private final SparseArray<Integer> mClientRequests = new SparseArray<>();
        private final SparseIntArray mClientRequests = new SparseIntArray();

        private ClientInfo(AsyncChannel c, Messenger m) {
            mChannel = c;
@@ -873,6 +859,7 @@ public class NsdService extends INsdManager.Stub {
        // and send cancellations to the daemon.
        private void expungeAllRequests() {
            int globalId, clientId, i;
            // TODO: to keep handler responsive, do not clean all requests for that client at once.
            for (i = 0; i < mClientIds.size(); i++) {
                clientId = mClientIds.keyAt(i);
                globalId = mClientIds.valueAt(i);
@@ -900,15 +887,11 @@ public class NsdService extends INsdManager.Stub {
        // mClientIds is a sparse array of listener id -> mDnsClient id.  For a given mDnsClient id,
        // return the corresponding listener id.  mDnsClient id is also called a global id.
        private int getClientId(final int globalId) {
            // This doesn't use mClientIds.indexOfValue because indexOfValue uses == (not .equals)
            // while also coercing the int primitives to Integer objects.
            for (int i = 0, nSize = mClientIds.size(); i < nSize; i++) {
                int mDnsId = mClientIds.valueAt(i);
                if (globalId == mDnsId) {
                    return mClientIds.keyAt(i);
                }
            int idx = mClientIds.indexOfValue(globalId);
            if (idx < 0) {
                return idx;
            }
            return -1;
            return mClientIds.keyAt(idx);
        }
    }

+84 −13
Original line number Diff line number Diff line
@@ -16,68 +16,121 @@

package com.android.server;

import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.os.Message;
import android.os.test.TestLooper;
import android.content.Context;
import android.content.ContentResolver;
import android.net.nsd.NsdManager;
import android.net.nsd.NsdServiceInfo;
import com.android.server.NsdService.DaemonConnection;
import com.android.server.NsdService.DaemonConnectionSupplier;
import com.android.server.NsdService.NativeCallbackReceiver;
import android.support.test.filters.SmallTest;
import android.support.test.runner.AndroidJUnit4;
import org.junit.After;
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;

// TODOs:
//  - test client disconnects
//  - test client can send requests and receive replies
//  - test NSD_ON ENABLE/DISABLED listening
@RunWith(AndroidJUnit4.class)
@SmallTest
public class NsdServiceTest {

    static final int PROTOCOL = NsdManager.PROTOCOL_DNS_SD;

    long mTimeoutMs = 100; // non-final so that tests can adjust the value.

    @Mock Context mContext;
    @Mock ContentResolver mResolver;
    @Mock NsdService.NsdSettings mSettings;
    @Mock DaemonConnection mDaemon;
    NativeCallbackReceiver mDaemonCallback;
    TestLooper mLooper;
    HandlerThread mThread;
    TestHandler mHandler;

    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
        mLooper = new TestLooper();
        mHandler = new TestHandler(mLooper.getLooper());
        mThread = new HandlerThread("mock-service-handler");
        mThread.start();
        mHandler = new TestHandler(mThread.getLooper());
        when(mContext.getContentResolver()).thenReturn(mResolver);
    }

    @After
    public void tearDown() throws Exception {
        mThread.quit();
    }

    @Test
    public void testClientsCanConnect() {
    public void testClientsCanConnectAndDisconnect() {
        when(mSettings.isEnabled()).thenReturn(true);

        NsdService service = makeService();

        NsdManager client1 = connectClient(service);
        verify(mDaemon, timeout(100).times(1)).execute("start-service");
        verify(mDaemon, timeout(100).times(1)).start();

        NsdManager client2 = connectClient(service);

        // TODO: disconnect client1
        // TODO: disconnect client2
        client1.disconnect();
        client2.disconnect();

        verify(mDaemon, timeout(mTimeoutMs).times(1)).stop();
    }

    @Test
    public void testClientRequestsAreGCedAtDisconnection() {
        when(mSettings.isEnabled()).thenReturn(true);
        when(mDaemon.execute(any())).thenReturn(true);

        NsdService service = makeService();
        NsdManager client = connectClient(service);

        verify(mDaemon, timeout(100).times(1)).start();

        NsdServiceInfo request = new NsdServiceInfo("a_name", "a_type");
        request.setPort(2201);

        // Client registration request
        NsdManager.RegistrationListener listener1 = mock(NsdManager.RegistrationListener.class);
        client.registerService(request, PROTOCOL, listener1);
        verifyDaemonCommand("register 2 a_name a_type 2201");

        // Client discovery request
        NsdManager.DiscoveryListener listener2 = mock(NsdManager.DiscoveryListener.class);
        client.discoverServices("a_type", PROTOCOL, listener2);
        verifyDaemonCommand("discover 3 a_type");

        // Client resolve request
        NsdManager.ResolveListener listener3 = mock(NsdManager.ResolveListener.class);
        client.resolveService(request, listener3);
        verifyDaemonCommand("resolve 4 a_name a_type local.");

        // Client disconnects
        client.disconnect();
        verify(mDaemon, timeout(mTimeoutMs).times(1)).stop();

        // checks that request are cleaned
        verifyDaemonCommands("stop-register 2", "stop-discover 3", "stop-resolve 4");
    }

    NsdService makeService() {
@@ -91,10 +144,28 @@ public class NsdServiceTest {
    }

    NsdManager connectClient(NsdService service) {
        mLooper.startAutoDispatch();
        NsdManager client = new NsdManager(mContext, service);
        mLooper.stopAutoDispatch();
        return client;
        return new NsdManager(mContext, service);
    }

    void verifyDaemonCommands(String... wants) {
        verifyDaemonCommand(String.join(" ", wants), wants.length);
    }

    void verifyDaemonCommand(String want) {
        verifyDaemonCommand(want, 1);
    }

    void verifyDaemonCommand(String want, int n) {
        ArgumentCaptor<Object> argumentsCaptor = ArgumentCaptor.forClass(Object.class);
        verify(mDaemon, timeout(mTimeoutMs).times(n)).execute(argumentsCaptor.capture());
        String got = "";
        for (Object o : argumentsCaptor.getAllValues()) {
            got += o + " ";
        }
        assertEquals(want, got.trim());
        // rearm deamon for next command verification
        reset(mDaemon);
        when(mDaemon.execute(any())).thenReturn(true);
    }

    public static class TestHandler extends Handler {