1/*
2 * Copyright (C) 2011 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 */
16package com.android.chimpchat.adb;
17
18import com.google.common.annotations.VisibleForTesting;
19import com.google.common.base.Preconditions;
20import com.google.common.collect.Lists;
21import com.google.common.collect.Maps;
22
23import com.android.ddmlib.AdbCommandRejectedException;
24import com.android.ddmlib.IDevice;
25import com.android.ddmlib.InstallException;
26import com.android.ddmlib.ShellCommandUnresponsiveException;
27import com.android.ddmlib.TimeoutException;
28import com.android.chimpchat.ChimpManager;
29import com.android.chimpchat.adb.LinearInterpolator.Point;
30import com.android.chimpchat.core.ChimpRect;
31import com.android.chimpchat.core.IChimpImage;
32import com.android.chimpchat.core.IChimpDevice;
33import com.android.chimpchat.core.IChimpView;
34import com.android.chimpchat.core.IMultiSelector;
35import com.android.chimpchat.core.ISelector;
36import com.android.chimpchat.core.PhysicalButton;
37import com.android.chimpchat.core.TouchPressType;
38import com.android.chimpchat.hierarchyviewer.HierarchyViewer;
39
40import java.io.IOException;
41import java.net.InetAddress;
42import java.net.Socket;
43import java.net.UnknownHostException;
44import java.util.ArrayList;
45import java.util.Collection;
46import java.util.List;
47import java.util.Map;
48import java.util.Map.Entry;
49import java.util.concurrent.ExecutorService;
50import java.util.concurrent.Executors;
51import java.util.logging.Level;
52import java.util.logging.Logger;
53import java.util.regex.Matcher;
54import java.util.regex.Pattern;
55
56import javax.annotation.Nullable;
57
58public class AdbChimpDevice implements IChimpDevice {
59    private static final Logger LOG = Logger.getLogger(AdbChimpDevice.class.getName());
60
61    private static final String[] ZERO_LENGTH_STRING_ARRAY = new String[0];
62    private static final long MANAGER_CREATE_TIMEOUT_MS = 30 * 1000; // 30 seconds
63    private static final long MANAGER_CREATE_WAIT_TIME_MS = 1000; // wait 1 second
64
65    private final ExecutorService executor = Executors.newSingleThreadExecutor();
66
67    private final IDevice device;
68    private ChimpManager manager;
69
70    public AdbChimpDevice(IDevice device) {
71        this.device = device;
72        this.manager = createManager("127.0.0.1", 12345);
73
74        Preconditions.checkNotNull(this.manager);
75    }
76
77    @Override
78    public ChimpManager getManager() {
79        return manager;
80    }
81
82    @Override
83    public void dispose() {
84        try {
85            manager.quit();
86        } catch (IOException e) {
87            LOG.log(Level.SEVERE, "Error getting the manager to quit", e);
88        }
89        manager.close();
90        executor.shutdown();
91        manager = null;
92    }
93
94    @Override
95    public HierarchyViewer getHierarchyViewer() {
96        return new HierarchyViewer(device);
97    }
98
99    private void executeAsyncCommand(final String command,
100            final LoggingOutputReceiver logger) {
101        executor.submit(new Runnable() {
102            @Override
103            public void run() {
104                try {
105                    device.executeShellCommand(command, logger);
106                } catch (TimeoutException e) {
107                    LOG.log(Level.SEVERE, "Error starting command: " + command, e);
108                    throw new RuntimeException(e);
109                } catch (AdbCommandRejectedException e) {
110                    LOG.log(Level.SEVERE, "Error starting command: " + command, e);
111                    throw new RuntimeException(e);
112                } catch (ShellCommandUnresponsiveException e) {
113                    // This happens a lot
114                    LOG.log(Level.INFO, "Error starting command: " + command, e);
115                    throw new RuntimeException(e);
116                } catch (IOException e) {
117                    LOG.log(Level.SEVERE, "Error starting command: " + command, e);
118                    throw new RuntimeException(e);
119                }
120            }
121        });
122    }
123
124    private ChimpManager createManager(String address, int port) {
125        try {
126            device.createForward(port, port);
127        } catch (TimeoutException e) {
128            LOG.log(Level.SEVERE, "Timeout creating adb port forwarding", e);
129            return null;
130        } catch (AdbCommandRejectedException e) {
131            LOG.log(Level.SEVERE, "Adb rejected adb port forwarding command: " + e.getMessage(), e);
132            return null;
133        } catch (IOException e) {
134            LOG.log(Level.SEVERE, "Unable to create adb port forwarding: " + e.getMessage(), e);
135            return null;
136        }
137
138        String command = "monkey --port " + port;
139        executeAsyncCommand(command, new LoggingOutputReceiver(LOG, Level.FINE));
140
141        // Sleep for a second to give the command time to execute.
142        try {
143            Thread.sleep(1000);
144        } catch (InterruptedException e) {
145            LOG.log(Level.SEVERE, "Unable to sleep", e);
146        }
147
148        InetAddress addr;
149        try {
150            addr = InetAddress.getByName(address);
151        } catch (UnknownHostException e) {
152            LOG.log(Level.SEVERE, "Unable to convert address into InetAddress: " + address, e);
153            return null;
154        }
155
156        // We have a tough problem to solve here.  "monkey" on the device gives us no indication
157        // when it has started up and is ready to serve traffic.  If you try too soon, commands
158        // will fail.  To remedy this, we will keep trying until a single command (in this case,
159        // wake) succeeds.
160        boolean success = false;
161        ChimpManager mm = null;
162        long start = System.currentTimeMillis();
163
164        while (!success) {
165            long now = System.currentTimeMillis();
166            long diff = now - start;
167            if (diff > MANAGER_CREATE_TIMEOUT_MS) {
168                LOG.severe("Timeout while trying to create chimp mananger");
169                return null;
170            }
171
172            try {
173                Thread.sleep(MANAGER_CREATE_WAIT_TIME_MS);
174            } catch (InterruptedException e) {
175                LOG.log(Level.SEVERE, "Unable to sleep", e);
176            }
177
178            Socket monkeySocket;
179            try {
180                monkeySocket = new Socket(addr, port);
181            } catch (IOException e) {
182                LOG.log(Level.FINE, "Unable to connect socket", e);
183                success = false;
184                continue;
185            }
186
187            try {
188                mm = new ChimpManager(monkeySocket);
189            } catch (IOException e) {
190                LOG.log(Level.SEVERE, "Unable to open writer and reader to socket");
191                continue;
192            }
193
194            try {
195                mm.wake();
196            } catch (IOException e) {
197                LOG.log(Level.FINE, "Unable to wake up device", e);
198                success = false;
199                continue;
200            }
201            success = true;
202        }
203
204        return mm;
205    }
206
207    @Override
208    public IChimpImage takeSnapshot() {
209        try {
210            return new AdbChimpImage(device.getScreenshot());
211        } catch (TimeoutException e) {
212            LOG.log(Level.SEVERE, "Unable to take snapshot", e);
213            return null;
214        } catch (AdbCommandRejectedException e) {
215            LOG.log(Level.SEVERE, "Unable to take snapshot", e);
216            return null;
217        } catch (IOException e) {
218            LOG.log(Level.SEVERE, "Unable to take snapshot", e);
219            return null;
220        }
221    }
222
223    @Override
224    public String getSystemProperty(String key) {
225        return device.getProperty(key);
226    }
227
228    @Override
229    public String getProperty(String key) {
230        try {
231            return manager.getVariable(key);
232        } catch (IOException e) {
233            LOG.log(Level.SEVERE, "Unable to get variable: " + key, e);
234            return null;
235        }
236    }
237
238    @Override
239    public Collection<String> getPropertyList() {
240        try {
241            return manager.listVariable();
242        } catch (IOException e) {
243            LOG.log(Level.SEVERE, "Unable to get variable list", e);
244            return null;
245        }
246    }
247
248    @Override
249    public void wake() {
250        try {
251            manager.wake();
252        } catch (IOException e) {
253            LOG.log(Level.SEVERE, "Unable to wake device (too sleepy?)", e);
254        }
255    }
256
257    private String shell(String... args) {
258        StringBuilder cmd = new StringBuilder();
259        for (String arg : args) {
260            cmd.append(arg).append(" ");
261        }
262        return shell(cmd.toString());
263    }
264
265    @Override
266    public String shell(String cmd) {
267        CommandOutputCapture capture = new CommandOutputCapture();
268        try {
269            device.executeShellCommand(cmd, capture);
270        } catch (TimeoutException e) {
271            LOG.log(Level.SEVERE, "Error executing command: " + cmd, e);
272            return null;
273        } catch (ShellCommandUnresponsiveException e) {
274            LOG.log(Level.SEVERE, "Error executing command: " + cmd, e);
275            return null;
276        } catch (AdbCommandRejectedException e) {
277            LOG.log(Level.SEVERE, "Error executing command: " + cmd, e);
278            return null;
279        } catch (IOException e) {
280            LOG.log(Level.SEVERE, "Error executing command: " + cmd, e);
281            return null;
282        }
283        return capture.toString();
284    }
285
286    @Override
287    public boolean installPackage(String path) {
288        try {
289            String result = device.installPackage(path, true);
290            if (result != null) {
291                LOG.log(Level.SEVERE, "Got error installing package: "+ result);
292                return false;
293            }
294            return true;
295        } catch (InstallException e) {
296            LOG.log(Level.SEVERE, "Error installing package: " + path, e);
297            return false;
298        }
299    }
300
301    @Override
302    public boolean removePackage(String packageName) {
303        try {
304            String result = device.uninstallPackage(packageName);
305            if (result != null) {
306                LOG.log(Level.SEVERE, "Got error uninstalling package "+ packageName + ": " +
307                        result);
308                return false;
309            }
310            return true;
311        } catch (InstallException e) {
312            LOG.log(Level.SEVERE, "Error installing package: " + packageName, e);
313            return false;
314        }
315    }
316
317    @Override
318    public void press(String keyName, TouchPressType type) {
319        try {
320            switch (type) {
321                case DOWN_AND_UP:
322                    manager.press(keyName);
323                    break;
324                case DOWN:
325                    manager.keyDown(keyName);
326                    break;
327                case UP:
328                    manager.keyUp(keyName);
329                    break;
330            }
331        } catch (IOException e) {
332            LOG.log(Level.SEVERE, "Error sending press event: " + keyName + " " + type, e);
333        }
334    }
335
336    @Override
337    public void press(PhysicalButton key, TouchPressType type) {
338      press(key.getKeyName(), type);
339    }
340
341    @Override
342    public void type(String string) {
343        try {
344            manager.type(string);
345        } catch (IOException e) {
346            LOG.log(Level.SEVERE, "Error Typing: " + string, e);
347        }
348    }
349
350    @Override
351    public void touch(int x, int y, TouchPressType type) {
352        try {
353            switch (type) {
354                case DOWN:
355                    manager.touchDown(x, y);
356                    break;
357                case UP:
358                    manager.touchUp(x, y);
359                    break;
360                case DOWN_AND_UP:
361                    manager.tap(x, y);
362                    break;
363            }
364        } catch (IOException e) {
365            LOG.log(Level.SEVERE, "Error sending touch event: " + x + " " + y + " " + type, e);
366        }
367    }
368
369    @Override
370    public void reboot(String into) {
371        try {
372            device.reboot(into);
373        } catch (TimeoutException e) {
374            LOG.log(Level.SEVERE, "Unable to reboot device", e);
375        } catch (AdbCommandRejectedException e) {
376            LOG.log(Level.SEVERE, "Unable to reboot device", e);
377        } catch (IOException e) {
378            LOG.log(Level.SEVERE, "Unable to reboot device", e);
379        }
380    }
381
382    @Override
383    public void startActivity(String uri, String action, String data, String mimetype,
384            Collection<String> categories, Map<String, Object> extras, String component,
385            int flags) {
386        List<String> intentArgs = buildIntentArgString(uri, action, data, mimetype, categories,
387                extras, component, flags);
388        shell(Lists.asList("am", "start",
389                intentArgs.toArray(ZERO_LENGTH_STRING_ARRAY)).toArray(ZERO_LENGTH_STRING_ARRAY));
390    }
391
392    @Override
393    public void broadcastIntent(String uri, String action, String data, String mimetype,
394            Collection<String> categories, Map<String, Object> extras, String component,
395            int flags) {
396        List<String> intentArgs = buildIntentArgString(uri, action, data, mimetype, categories,
397                extras, component, flags);
398        shell(Lists.asList("am", "broadcast",
399                intentArgs.toArray(ZERO_LENGTH_STRING_ARRAY)).toArray(ZERO_LENGTH_STRING_ARRAY));
400    }
401
402    private static boolean isNullOrEmpty(@Nullable String string) {
403        return string == null || string.length() == 0;
404    }
405
406    private List<String> buildIntentArgString(String uri, String action, String data, String mimetype,
407            Collection<String> categories, Map<String, Object> extras, String component,
408            int flags) {
409        List<String> parts = Lists.newArrayList();
410
411        // from adb docs:
412        //<INTENT> specifications include these flags:
413        //    [-a <ACTION>] [-d <DATA_URI>] [-t <MIME_TYPE>]
414        //    [-c <CATEGORY> [-c <CATEGORY>] ...]
415        //    [-e|--es <EXTRA_KEY> <EXTRA_STRING_VALUE> ...]
416        //    [--esn <EXTRA_KEY> ...]
417        //    [--ez <EXTRA_KEY> <EXTRA_BOOLEAN_VALUE> ...]
418        //    [-e|--ei <EXTRA_KEY> <EXTRA_INT_VALUE> ...]
419        //    [-n <COMPONENT>] [-f <FLAGS>]
420        //    [<URI>]
421
422        if (!isNullOrEmpty(action)) {
423            parts.add("-a");
424            parts.add(action);
425        }
426
427        if (!isNullOrEmpty(data)) {
428            parts.add("-d");
429            parts.add(data);
430        }
431
432        if (!isNullOrEmpty(mimetype)) {
433            parts.add("-t");
434            parts.add(mimetype);
435        }
436
437        // Handle categories
438        for (String category : categories) {
439            parts.add("-c");
440            parts.add(category);
441        }
442
443        // Handle extras
444        for (Entry<String, Object> entry : extras.entrySet()) {
445            // Extras are either boolean, string, or int.  See which we have
446            Object value = entry.getValue();
447            String valueString;
448            String arg;
449            if (value instanceof Integer) {
450                valueString = Integer.toString((Integer) value);
451                arg = "--ei";
452            } else if (value instanceof Boolean) {
453                valueString = Boolean.toString((Boolean) value);
454                arg = "--ez";
455            } else {
456                // treat is as a string.
457                valueString = value.toString();
458                arg = "--es";
459            }
460            parts.add(arg);
461            parts.add(entry.getKey());
462            parts.add(valueString);
463        }
464
465        if (!isNullOrEmpty(component)) {
466            parts.add("-n");
467            parts.add(component);
468        }
469
470        if (flags != 0) {
471            parts.add("-f");
472            parts.add(Integer.toString(flags));
473        }
474
475        if (!isNullOrEmpty(uri)) {
476            parts.add(uri);
477        }
478
479        return parts;
480    }
481
482    @Override
483    public Map<String, Object> instrument(String packageName, Map<String, Object> args) {
484        List<String> shellCmd = Lists.newArrayList("am", "instrument", "-w", "-r");
485        for (Entry<String, Object> entry: args.entrySet()) {
486            final String key = entry.getKey();
487            final Object value = entry.getValue();
488            if (key != null && value != null) {
489                shellCmd.add("-e");
490                shellCmd.add(key);
491                shellCmd.add(value.toString());
492            }
493        }
494        shellCmd.add(packageName);
495        String result = shell(shellCmd.toArray(ZERO_LENGTH_STRING_ARRAY));
496        return convertInstrumentResult(result);
497    }
498
499    /**
500     * Convert the instrumentation result into it's Map representation.
501     *
502     * @param result the result string
503     * @return the new map
504     */
505    @VisibleForTesting
506    /* package */ static Map<String, Object> convertInstrumentResult(String result) {
507        Map<String, Object> map = Maps.newHashMap();
508        Pattern pattern = Pattern.compile("^INSTRUMENTATION_(\\w+): ", Pattern.MULTILINE);
509        Matcher matcher = pattern.matcher(result);
510
511        int previousEnd = 0;
512        String previousWhich = null;
513
514        while (matcher.find()) {
515            if ("RESULT".equals(previousWhich)) {
516                String resultLine = result.substring(previousEnd, matcher.start()).trim();
517                // Look for the = in the value, and split there
518                int splitIndex = resultLine.indexOf("=");
519                String key = resultLine.substring(0, splitIndex);
520                String value = resultLine.substring(splitIndex + 1);
521
522                map.put(key, value);
523            }
524
525            previousEnd = matcher.end();
526            previousWhich = matcher.group(1);
527        }
528        if ("RESULT".equals(previousWhich)) {
529            String resultLine = result.substring(previousEnd, matcher.start()).trim();
530            // Look for the = in the value, and split there
531            int splitIndex = resultLine.indexOf("=");
532            String key = resultLine.substring(0, splitIndex);
533            String value = resultLine.substring(splitIndex + 1);
534
535            map.put(key, value);
536        }
537        return map;
538    }
539
540    @Override
541    public void drag(int startx, int starty, int endx, int endy, int steps, long ms) {
542        final long iterationTime = ms / steps;
543
544        LinearInterpolator lerp = new LinearInterpolator(steps);
545        LinearInterpolator.Point start = new LinearInterpolator.Point(startx, starty);
546        LinearInterpolator.Point end = new LinearInterpolator.Point(endx, endy);
547        lerp.interpolate(start, end, new LinearInterpolator.Callback() {
548            @Override
549            public void step(Point point) {
550                try {
551                    manager.touchMove(point.getX(), point.getY());
552                } catch (IOException e) {
553                    LOG.log(Level.SEVERE, "Error sending drag start event", e);
554                }
555
556                try {
557                    Thread.sleep(iterationTime);
558                } catch (InterruptedException e) {
559                    LOG.log(Level.SEVERE, "Error sleeping", e);
560                }
561            }
562
563            @Override
564            public void start(Point point) {
565                try {
566                    manager.touchDown(point.getX(), point.getY());
567                    manager.touchMove(point.getX(), point.getY());
568                } catch (IOException e) {
569                    LOG.log(Level.SEVERE, "Error sending drag start event", e);
570                }
571
572                try {
573                    Thread.sleep(iterationTime);
574                } catch (InterruptedException e) {
575                    LOG.log(Level.SEVERE, "Error sleeping", e);
576                }
577            }
578
579            @Override
580            public void end(Point point) {
581                try {
582                    manager.touchMove(point.getX(), point.getY());
583                    manager.touchUp(point.getX(), point.getY());
584                } catch (IOException e) {
585                    LOG.log(Level.SEVERE, "Error sending drag end event", e);
586                }
587            }
588        });
589    }
590
591
592    @Override
593    public Collection<String> getViewIdList() {
594        try {
595            return manager.listViewIds();
596        } catch(IOException e) {
597            LOG.log(Level.SEVERE, "Error retrieving view IDs", e);
598            return new ArrayList<String>();
599        }
600    }
601
602    @Override
603    public IChimpView getView(ISelector selector) {
604        return selector.getView(manager);
605    }
606
607    @Override
608    public Collection<IChimpView> getViews(IMultiSelector selector) {
609        return selector.getViews(manager);
610    }
611
612    @Override
613    public IChimpView getRootView() {
614        try {
615            return manager.getRootView();
616        } catch (IOException e) {
617            LOG.log(Level.SEVERE, "Error retrieving root view");
618            return null;
619        }
620    }
621}
622