diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..2816b580 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.gradle +/local.properties +/.idea +*.iml +.DS_Store +/build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..928efcca --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# Contributing to Stetho +We want to make contributing to this project as easy and transparent as +possible. + +## Our Development Process +We work directly in the github project and provide versioned releases +appropriate for major milestones and minor bug fixes or improvements. GitHub +is used directly for issues and pull requests and the developers actively +respond to requests. + +## Pull Requests +We actively welcome your pull requests. +1. Fork the repo and create your branch from `master`. +2. If you've added code that should be tested, add tests +3. If you've changed APIs, update the documentation. +4. Ensure the test suite passes. +5. Make sure your code lints. +6. If you haven't already, complete the Contributor License Agreement ("CLA"). + +## Contributor License Agreement ("CLA") +In order to accept your pull request, we need you to submit a CLA. You only need +to do this once to work on any of Facebook's open source projects. + +Complete your CLA here: + +## Issues +We use GitHub issues to track public bugs. Please ensure your description is +clear and has sufficient instructions to be able to reproduce the issue. + +Facebook has a [bounty program](https://www.facebook.com/whitehat/) for the safe +disclosure of security bugs. In those cases, please go through the process +outlined on that page and do not file a public issue. + +## Coding Style +* 2 spaces for indentation rather than tabs +* Line wrapping indents 4 spaces +* 100 character line length +* One parameter per line when line wrapping is required +* Use the `m` member variable prefix for private fields +* Opening braces to appear on the same line as code + +## License +By contributing to Stetho, you agree that your contributions will be licensed +under its BSD license. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..0410e123 --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +BSD License + +For Stetho software + +Copyright (c) 2015, Facebook, Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/PATENTS b/PATENTS new file mode 100644 index 00000000..0b0e455d --- /dev/null +++ b/PATENTS @@ -0,0 +1,23 @@ +Additional Grant of Patent Rights + +"Software" means the Stetho software distributed by Facebook, Inc. + +Facebook hereby grants you a perpetual, worldwide, royalty-free, non-exclusive, +irrevocable (subject to the termination provision below) license under any +rights in any patent claims owned by Facebook, to make, have made, use, sell, +offer to sell, import, and otherwise transfer the Software. For avoidance of +doubt, no license is granted under Facebook’s rights in any patent claims that +are infringed by (i) modifications to the Software made by you or a third party, +or (ii) the Software in combination with any software or other technology +provided by you or a third party. + +The license granted hereunder will terminate, automatically and without notice, +for anyone that makes any claim (including by filing any lawsuit, assertion or +other action) alleging (a) direct, indirect, or contributory infringement or +inducement to infringe any patent: (i) by Facebook or any of its subsidiaries or +affiliates, whether or not such claim is related to the Software, (ii) by any +party if such claim arises in whole or in part from any software, product or +service of Facebook or any of its subsidiaries or affiliates, whether or not +such claim is related to the Software, or (iii) by any party relating to the +Software; or (b) that any right in any patent claim of Facebook is invalid or +unenforceable. diff --git a/README.md b/README.md new file mode 100644 index 00000000..998bf5d5 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# Stetho +Stetho is a sophisticated debug bridge for Android applications. When enabled, +developers have access to the Chrome Developer Tools feature natively part of +the Chrome desktop browser. Developers can also choose to enable the optional +`dumpapp` tool which offers a powerful command-line interface to application +internals. + +## Features + +### WebKit Inspector +WebKit Inspector is the internal name of the Chrome Developer Tools feature. +It is implemented using a client/server protocol which the Stetho software +provides for your application. Once your application is integrated, simply +navigate to `chrome://inspect` and click "Inspect" to get started! + +![Inspector Discovery Screenshot](https://github.com/facebook/stetho/raw/master/docs/images/inspector-discovery.png) + +#### Network inspection +Network inspection is possible with the full spectrum of Chrome Developer Tools features, including image preview, JSON response helpers, and even exporting traces to the HAR format. + +![Inspector Network Screenshot](https://github.com/facebook/stetho/raw/master/docs/images/inspector-network.png) + +#### Database inspection +SQLite databases can be visualized and interactively explored with full read/write capabilities. + +![Inspector WebSQL Screenshot](https://github.com/facebook/stetho/raw/master/docs/images/inspector-sqlite.png) + +### dumpapp +Dumpapp extends beyond the Inspector UI features shown above to provide a much +more extensible, command-line interface to application components. A default +set of plugins is provided, but the real power of dumpapp is the ability to +easily create your own! + +![dumpapp prefs Screenshot](https://github.com/facebook/stetho/raw/master/docs/images/dumpapp-prefs.png) + +## Integration +Integrating with Stetho is intended to be seamless and straightforward for +most existing Android applications. There is a simple initialization step +which occurs in your `Application` class: + +```java +public class MyApplication extends Application { + public void onCreate() { + super.onCreate(); + Stetho.initialize( + Stetho.newInitializerBuilder(this) + .enableDumpapp(Stetho.defaultDumperPluginsProvider(this)) + .enableWebKitInspector(Stetho.defaultInspectorModulesProvider(this)) + .build()); + } +} +``` + +This brings up most of the default configuration but does not enable some +additional hooks (most notably, network inspection). See below for specific +details on individual subsystems. + +### Enable network inspection +If you are using the popular [OkHttp](http://https://github.com/square/okio) +library at the 2.2.x+ release, you can use the +[Interceptors](https://github.com/square/okhttp/wiki/Interceptors) system to +automatically hook into your existing stack. This is currently the simplest +and most straightforward way to enable network inspection: + +```java +OkHttpClient client = new OkHttpClient(); +client.networkInterceptors().add(new StethoInterceptor()); +``` + +If you are using any of other network stack options, you will need to manually +provide data to the `NetworkEventReporter` interface. The general pattern for implementing this is: + +```java +NetworkEventReporter reporter = NetworkEventReporterImpl.get(); +// Important to check if it is enabled first so as not to add overhead to +// the common case that is not under scrutiny. +if (reporter.isEnabled()) { + reporter.requestWillBeSent(new MyInspectorRequest(request)); +} +``` + +See the `stetho-sample` project for more details. + +### Custom dumpapp plugins +Custom plugins are the preferred means of extending the `dumpapp` system and +can be added easily during configuration. Simply replace your configuration +step as such: + +```java +Stetho.initialize(Stetho.newInitializerBuilder(context) + .enableDumpapp(new MyDumperPluginsProvider(context)) + .build()) + +private static class MyDumperPluginsProvider implements DumperPluginsProvider { + ... + + public Iterable get() { + ArrayList plugins = new ArrayList(); + for (DumperPlugin defaultPlugin : Stetho.defaultDumperPluginsProvider(mContext).get()) { + plugins.add(defaultPlugin); + } + plugins.add(new MyDumperPlugin()); + return plugins; + } +} +``` + +See the `stetho-sample` project for more details. + +## Improve Stetho! +See the CONTRIBUTING.md file for how to help out. + +## License +Stetho is BSD-licensed. We also provide an additional patent grant. diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..6fd32e27 --- /dev/null +++ b/build.gradle @@ -0,0 +1,15 @@ +buildscript { + repositories { + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:1.0.0' + classpath 'org.robolectric:robolectric-gradle-plugin:0.14.+' + } +} + +allprojects { + repositories { + jcenter() + } +} diff --git a/docs/images/dumpapp-prefs.png b/docs/images/dumpapp-prefs.png new file mode 100644 index 00000000..08aeadec Binary files /dev/null and b/docs/images/dumpapp-prefs.png differ diff --git a/docs/images/inspector-discovery.png b/docs/images/inspector-discovery.png new file mode 100644 index 00000000..77c8a974 Binary files /dev/null and b/docs/images/inspector-discovery.png differ diff --git a/docs/images/inspector-network.png b/docs/images/inspector-network.png new file mode 100644 index 00000000..706b2a4e Binary files /dev/null and b/docs/images/inspector-network.png differ diff --git a/docs/images/inspector-sqlite.png b/docs/images/inspector-sqlite.png new file mode 100644 index 00000000..27c8eed4 Binary files /dev/null and b/docs/images/inspector-sqlite.png differ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..c97a8bdb Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..519b6d57 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Jan 28 14:53:04 PST 2015 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-2.2.1-bin.zip diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..91a7e269 --- /dev/null +++ b/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..aec99730 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/scripts/dumpapp b/scripts/dumpapp new file mode 100755 index 00000000..0c0e0abe --- /dev/null +++ b/scripts/dumpapp @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + +import sys +import os +import io +from http.client import * +import urllib.parse + +from stetho_open import * + +def main(): + # Manually parse out -p , all other option handling occurs inside + # the hosting process. + process = None + args = sys.argv[1:] + if len(args) > 0 and (args[0] == '-p' or args[0] == '--process'): + if len(args) < 2: + print(sys.stderr, 'Missing ') + sys.exit(1) + else: + process = args[1] + args = args[2:] + + # Connect to ANDROID_SERIAL if supplied, otherwise fallback to any + # transport. + device = os.environ.get('ANDROID_SERIAL') + try: + conn = HTTPConnectionOverADB(device, process) + query_params = ['argv=' + urllib.parse.quote(arg) for arg in args] + url = '/dumpapp?%s' % ('&'.join(query_params)) + fake_host = { 'Host': 'localhost:5037' } + + http_method = None + body = None + if not sys.stdin.isatty(): + http_method = 'POST' + body = sys.stdin.detach().read() + else: + http_method = 'GET' + body = None + + conn.request(http_method, url, body, fake_host) + + reply = conn.getresponse() + + if reply.status != 200: + raise Exception('Unexpected HTTP reply from Stetho: %d' % (reply.status)) + + read_frames(reply) + except HumanReadableError as e: + print(e) + sys.exit(1) + except KeyboardInterrupt: + sys.exit(1) + +def read_frames(conn): + while True: + # All frames have a single character code followed by a big-endian int + code = read_input(conn, 1, 'code') + n = struct.unpack('!L', read_input(conn, 4, 'int4'))[0] + + if code == b'1': + if n > 0: + sys.stdout.buffer.write(read_input(conn, n, 'stdout blob')) + elif code == b'2': + if n > 0: + sys.stderr.buffer.write(read_input(conn, n, 'stderr blob')) + sys.stderr.buffer.flush() + elif code == b'x': + sys.exit(n) + else: + if raise_on_eof: + raise IOError('Unexpected header: %s' % code) + break + +def read_input(conn, n, tag): + data = conn.read(n) + if not data or len(data) != n: + raise IOError('Unexpected end of stream while reading %s.' % tag) + return data + +class HTTPConnectionOverADB(HTTPConnection): + def __init__(self, device, process): + super(HTTPConnectionOverADB, self).__init__('localhost', 5037) + self._device = device + self._process = process + + def connect(self): + self.sock = stetho_open(device=self._device, process=self._process) + +if __name__ == '__main__': + main() diff --git a/scripts/stetho_open.py b/scripts/stetho_open.py new file mode 100755 index 00000000..a7355378 --- /dev/null +++ b/scripts/stetho_open.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +############################################################################### +## +## Simple utility class to create a forwarded socket connection to an +## application's stetho domain socket. +## +## Usage: +## +## sock = stetho_open( +## device='', +## process='com.facebook.stetho.sample') +## doHttp(sock) +## +############################################################################### + +import socket +import struct + +def stetho_open(device=None, process=None): + adb = _connect_to_device(device) + + socket_name = None + if process is None: + socket_name = _find_only_stetho_socket(device) + else: + socket_name = _format_process_as_stetho_socket(process) + + try: + adb.select_service('localabstract:%s' % (socket_name)) + except SelectServiceError as e: + raise HumanReadableError( + 'Failure to target process %s: %s (is it running?)' % ( + process, e.reason)) + + return adb.sock + +def _find_only_stetho_socket(device): + adb = _connect_to_device(device) + try: + adb.select_service('shell:cat /proc/net/unix') + last_stetho_socket_name = None + process_names = [] + for line in adb.sock.makefile(): + row = line.rstrip().split(' ') + if len(row) > 7: + socket_name = row[7] + if socket_name.startswith('@stetho_'): + last_stetho_socket_name = socket_name[1:] + process_names.append( + _parse_process_from_stetho_socket(socket_name)) + if len(process_names) > 1: + raise HumanReadableError( + 'Multiple stetho-enabled processes available:%s\n' % ( + '\n\t'.join([''] + process_names)) + + 'Use -p to select one') + elif last_stetho_socket_name == None: + raise HumanReadableError('No stetho-enabled processes running') + else: + return last_stetho_socket_name + finally: + adb.sock.close() + +def _connect_to_device(device=None): + adb = AdbSmartSocketClient() + adb.connect() + + try: + if device is None: + adb.select_service('host:transport-any') + else: + adb.select_service('host:transport:%s' % (device)) + + return adb + except SelectServiceError as e: + raise HumanReadableError( + 'Failure to target device %s: %s' % (device, e.reason)) + +def _parse_process_from_stetho_socket(socket_name): + parts = socket_name.split('_') + if len(parts) < 2 or parts[0] != '@stetho': + raise Exception('Unexpected Stetho socket formatting: %s' % (socket_name)) + if parts[-2:] == [ 'devtools', 'remote' ]: + return '.'.join(parts[1:-2]) + else: + return '.'.join(parts[1:]) + +def _format_process_as_stetho_socket(process): + filtered = process.replace('.', '_').replace(':', '_') + return 'stetho_%s_devtools_remote' % (filtered) + +class AdbSmartSocketClient(object): + """Implements the smartsockets system defined by: + https://android.googlesource.com/platform/system/core/+/master/adb/protocol.txt + """ + + def __init__(self): + pass + + def connect(self, port=5037): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect(('127.0.0.1', port)) + self.sock = sock + + def select_service(self, service): + message = '%04x%s' % (len(service), service) + self.sock.send(message.encode('ascii')) + status = self._read_exactly(4) + if status == b'OKAY': + # All good... + pass + elif status == b'FAIL': + reason_len = int(self._read_exactly(4), 16) + reason = self._read_exactly(reason_len).decode('ascii') + raise SelectServiceError(reason) + else: + raise Exception('Unrecognized status=%s' % (status)) + + def _read_exactly(self, num_bytes): + buf = b'' + while len(buf) < num_bytes: + new_buf = self.sock.recv(num_bytes) + buf += new_buf + return buf + +class SelectServiceError(Exception): + def __init__(self, reason): + self.reason = reason + + def __str__(self): + return repr(self.reason) + +class HumanReadableError(Exception): + def __init__(self, reason): + self.reason = reason + + def __str__(self): + return self.reason diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..d8732386 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +include ':stetho' +include ':stetho-sample' diff --git a/stetho-sample/.gitignore b/stetho-sample/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/stetho-sample/.gitignore @@ -0,0 +1 @@ +/build diff --git a/stetho-sample/build.gradle b/stetho-sample/build.gradle new file mode 100644 index 00000000..da932c58 --- /dev/null +++ b/stetho-sample/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'com.android.application' + +android { + compileSdkVersion 21 + buildToolsVersion "21.1.2" + + defaultConfig { + applicationId "com.facebook.stetho.sample" + minSdkVersion 11 + targetSdkVersion 21 + versionCode 1 + versionName "1.0" + } +} + +dependencies { + compile project(':stetho') + compile 'com.google.code.findbugs:jsr305:2.0.1' +} diff --git a/stetho-sample/src/main/AndroidManifest.xml b/stetho-sample/src/main/AndroidManifest.xml new file mode 100644 index 00000000..60d076ef --- /dev/null +++ b/stetho-sample/src/main/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/stetho-sample/src/main/java/com/facebook/stetho/sample/APODActivity.java b/stetho-sample/src/main/java/com/facebook/stetho/sample/APODActivity.java new file mode 100644 index 00000000..43eba822 --- /dev/null +++ b/stetho-sample/src/main/java/com/facebook/stetho/sample/APODActivity.java @@ -0,0 +1,193 @@ +package com.facebook.stetho.sample; + +import java.io.IOException; + +import android.app.ListActivity; +import android.app.LoaderManager; +import android.content.Context; +import android.content.CursorLoader; +import android.content.Intent; +import android.content.Loader; +import android.database.CharArrayBuffer; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.BitmapDrawable; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CursorAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +/** + * Simple demonstration of fetching and caching a specific RSS feed showing the + * "Astronomy Picture of the Day" feed from NASA. This demonstrates both the database access + * and network inspection features of Stetho. + */ +public class APODActivity extends ListActivity { + private static final int LOADER_APOD_POSTS = 1; + + private static final String TAG = "APODActivity"; + + private APODPostsAdapter mAdapter; + + public static void show(Context context) { + context.startActivity(new Intent(context, APODActivity.class)); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getLoaderManager().initLoader(LOADER_APOD_POSTS, new Bundle(), mLoaderCallback); + + new APODRssFetcher(getContentResolver()).fetchAndStore(); + + mAdapter = new APODPostsAdapter(this); + setListAdapter(mAdapter); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + getLoaderManager().destroyLoader(LOADER_APOD_POSTS); + } + + private final LoaderManager.LoaderCallbacks mLoaderCallback = + new LoaderManager.LoaderCallbacks() { + @Override + public Loader onCreateLoader(int id, Bundle args) { + switch (id) { + case LOADER_APOD_POSTS: + return APODPostsQuery.createCursorLoader(APODActivity.this); + default: + throw new IllegalArgumentException("id=" + id); + } + } + + @Override + public void onLoadFinished(Loader loader, Cursor data) { + mAdapter.changeCursor(data); + } + + @Override + public void onLoaderReset(Loader loader) { + mAdapter.changeCursor(null); + } + }; + + private class APODPostsAdapter extends CursorAdapter { + private final LayoutInflater mInflater; + + public APODPostsAdapter(Context context) { + super(context, null /* cursor */, false /* autoRequery */); + mInflater = LayoutInflater.from(context); + } + + @Override + public View newView(Context context, Cursor cursor, ViewGroup parent) { + View view = mInflater.inflate(R.layout.apod_list_item, parent, false); + view.setTag(new ViewHolder(view)); + return view; + } + + @Override + public void bindView(View view, Context context, Cursor cursor) { + ViewHolder holder = (ViewHolder)view.getTag(); + int bindPosition = cursor.getPosition(); + holder.position = bindPosition; + + final String imageUrl = cursor.getString(APODPostsQuery.DESCRIPTION_IMAGE_URL_INDEX); + holder.image.setImageDrawable(null); + fetchImage(imageUrl, bindPosition, holder); + + cursor.copyStringToBuffer(APODPostsQuery.TITLE_INDEX, holder.titleBuffer); + + setTextWithBuffer(holder.title, holder.titleBuffer); + + cursor.copyStringToBuffer(APODPostsQuery.DESCRIPTION_TEXT_INDEX, holder.descriptionBuffer); + setTextWithBuffer(holder.description, holder.descriptionBuffer); + } + + // Really crude image handling. Please don't do this in a real app :) + private void fetchImage( + final String imageUrl, + final int bindPosition, + final ViewHolder holder) { + Networker.HttpRequest imageRequest = Networker.HttpRequest.newBuilder() + .method(Networker.HttpMethod.GET) + .url(imageUrl) + .build(); + Networker.get().submit(imageRequest, new Networker.Callback() { + @Override + public void onResponse(Networker.HttpResponse result) { + if (bindPosition == holder.position) { + Log.d(TAG, "Got " + imageUrl + ": " + result.statusCode + ", " + result.body.length); + if (result.statusCode == 200) { + final Bitmap bitmap = + BitmapFactory.decodeByteArray(result.body, 0, result.body.length); + APODActivity.this.runOnUiThread(new Runnable() { + @Override + public void run() { + holder.image.setImageDrawable(new BitmapDrawable(bitmap)); + } + }); + } + } + } + + @Override + public void onFailure(IOException e) { + // Let Stetho demonstrate the errors :) + } + }); + } + } + + private static class ViewHolder { + public final ImageView image; + public final TextView title; + public final CharArrayBuffer titleBuffer = new CharArrayBuffer(32); + public final TextView description; + public final CharArrayBuffer descriptionBuffer = new CharArrayBuffer(64); + + int position; + + public ViewHolder(View v) { + image = (ImageView)v.findViewById(R.id.image); + title = (TextView)v.findViewById(R.id.title); + description = (TextView)v.findViewById(R.id.description); + } + } + + private static void setTextWithBuffer(TextView textView, CharArrayBuffer buffer) { + textView.setText(buffer.data, 0, buffer.sizeCopied); + } + + private static class APODPostsQuery { + public static String[] PROJECTION = { + APODContract.Columns._ID, + APODContract.Columns.TITLE, + APODContract.Columns.DESCRIPTION_TEXT, + APODContract.Columns.DESCRIPTION_IMAGE_URL, + }; + + public static final int ID_INDEX = 0; + public static final int TITLE_INDEX = 1; + public static final int DESCRIPTION_TEXT_INDEX = 2; + public static final int DESCRIPTION_IMAGE_URL_INDEX = 3; + + public static CursorLoader createCursorLoader(Context context) { + return new CursorLoader( + context, + APODContract.CONTENT_URI, + PROJECTION, + null /* selection */, + null /* selectionArgs */, + null /* sortOrder */); + } + } +} diff --git a/stetho-sample/src/main/java/com/facebook/stetho/sample/APODContentProvider.java b/stetho-sample/src/main/java/com/facebook/stetho/sample/APODContentProvider.java new file mode 100644 index 00000000..05c0ad1f --- /dev/null +++ b/stetho-sample/src/main/java/com/facebook/stetho/sample/APODContentProvider.java @@ -0,0 +1,120 @@ +package com.facebook.stetho.sample; + +import java.util.ArrayList; + +import android.content.ContentProvider; +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentValues; +import android.content.Context; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; + +public class APODContentProvider extends ContentProvider { + private APODSQLiteOpenHelper mOpenHelper; + + @Override + public boolean onCreate() { + mOpenHelper = new APODSQLiteOpenHelper(getContext()); + return true; + } + + @Override + public Cursor query( + Uri uri, + String[] projection, + String selection, + String[] selectionArgs, + String sortOrder) { + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + Cursor cursor = db.query( + APODContract.TABLE_NAME, + projection, + selection, + selectionArgs, + null /* groupBy */, + null /* having */, + sortOrder, + null /* limit */); + cursor.setNotificationUri(getContext().getContentResolver(), APODContract.CONTENT_URI); + return cursor; + } + + @Override + public String getType(Uri uri) { + return null; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + long id = db.insert(APODContract.TABLE_NAME, null /* nullColumnHack */, values); + return uri.buildUpon().appendEncodedPath(String.valueOf(id)).build(); + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + int count = db.delete(APODContract.TABLE_NAME, selection, selectionArgs); + return count; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + int count = db.update(APODContract.TABLE_NAME, values, selection, selectionArgs); + return count; + } + + @Override + public ContentProviderResult[] applyBatch(ArrayList operations) + throws OperationApplicationException { + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try { + ContentProviderResult[] results = super.applyBatch(operations); + db.setTransactionSuccessful(); + return results; + } finally { + db.endTransaction(); + notifyChange(); + } + } + + private void notifyChange() { + getContext().getContentResolver().notifyChange(APODContract.CONTENT_URI, null /* observer */); + } + + private static class APODSQLiteOpenHelper extends SQLiteOpenHelper { + private static final String DB_NAME = "apod.db"; + private static final int DB_VERSION = 1; + + public APODSQLiteOpenHelper(Context context) { + super(context, DB_NAME, null /* factory */, DB_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL( + "CREATE TABLE " + APODContract.TABLE_NAME + " (" + + APODContract.Columns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + APODContract.Columns.TITLE + " TEXT, " + + APODContract.Columns.DESCRIPTION_IMAGE_URL + " TEXT, " + + APODContract.Columns.DESCRIPTION_TEXT + " TEXT " + + ")"); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + drop(db); + onCreate(db); + } + + private void drop(SQLiteDatabase db) { + db.execSQL("DROP TABLE " + APODContract.TABLE_NAME); + } + } +} diff --git a/stetho-sample/src/main/java/com/facebook/stetho/sample/APODContract.java b/stetho-sample/src/main/java/com/facebook/stetho/sample/APODContract.java new file mode 100644 index 00000000..eda7fb18 --- /dev/null +++ b/stetho-sample/src/main/java/com/facebook/stetho/sample/APODContract.java @@ -0,0 +1,17 @@ +package com.facebook.stetho.sample; + +import android.net.Uri; +import android.provider.BaseColumns; + +public interface APODContract { + public static final String AUTHORITY = "com.facebook.stetho.sample.apod"; + public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY); + + public static final String TABLE_NAME = "rss_items"; + + public interface Columns extends BaseColumns { + public static final String TITLE = "title"; + public static final String DESCRIPTION_TEXT = "description_text"; + public static final String DESCRIPTION_IMAGE_URL = "description_image_url"; + } +} diff --git a/stetho-sample/src/main/java/com/facebook/stetho/sample/APODRssFetcher.java b/stetho-sample/src/main/java/com/facebook/stetho/sample/APODRssFetcher.java new file mode 100644 index 00000000..09f8249b --- /dev/null +++ b/stetho-sample/src/main/java/com/facebook/stetho/sample/APODRssFetcher.java @@ -0,0 +1,220 @@ +package com.facebook.stetho.sample; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import android.content.ContentProviderOperation; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.OperationApplicationException; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.os.RemoteException; +import android.text.Html; +import android.util.Log; +import android.util.Xml; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +public class APODRssFetcher { + private static final String TAG = "APODRssFetcher"; + private static final String APOD_RSS_URL = "http://apod.nasa.gov/apod.rss"; + + private final ContentResolver mContentResolver; + + public APODRssFetcher(ContentResolver contentResolver) { + mContentResolver = contentResolver; + } + + public void fetchAndStore() { + Networker.HttpRequest request = Networker.HttpRequest.newBuilder() + .friendlyName("APOD RSS") + .method(Networker.HttpMethod.GET) + .url(APOD_RSS_URL) + .build(); + Networker.get().submit(request, mStoreRssResponse); + } + + private final Networker.Callback mStoreRssResponse = new Networker.Callback() { + @Override + public void onResponse(Networker.HttpResponse result) { + if (result.statusCode == 200) { + try { + parseAndStore(result.body); + } catch (XmlPullParserException e) { + Log.e(TAG, "Parse error", e); + } catch (OperationApplicationException e) { + Log.e(TAG, "Database write error", e); + } catch (RemoteException e) { + // Not recoverable, our process or the system_server must be dying... + throw new RuntimeException(e); + } catch (IOException e) { + // Reading from a byte[] shouldn't cause this... + throw new RuntimeException(e); + } + } + } + + @Override + public void onFailure(IOException e) { + // Show in Stetho :) + } + + private void parseAndStore(byte[] body) + throws + XmlPullParserException, + RemoteException, + OperationApplicationException, + IOException { + XmlPullParser parser = Xml.newPullParser(); + parser.setInput(new ByteArrayInputStream(body), "UTF-8"); + List items = new RssParser(parser).parse(); + Log.d(TAG, "Fetched " + items.size() + " items"); + + ArrayList operations = new ArrayList(); + operations.add( + ContentProviderOperation.newDelete(APODContract.CONTENT_URI) + .build()); + for (RssItem item : items) { + Log.d(TAG, "Add item: " + item.title); + operations.add( + ContentProviderOperation.newInsert(APODContract.CONTENT_URI) + .withValues(convertItemToValues(item)) + .build()); + } + + mContentResolver.applyBatch(APODContract.AUTHORITY, operations); + } + + private ContentValues convertItemToValues(RssItem item) { + ContentValues values = new ContentValues(); + values.put(APODContract.Columns.TITLE, item.title); + + ExtractImageGetter imageGetter = new ExtractImageGetter(); + String strippedText = Html.fromHtml( + item.description, + imageGetter, + null /* tagHandler */) + .toString(); + + // Hack to remove some strange non-printing character at the start... + strippedText = strippedText.substring(1).trim(); + List imageUrls = imageGetter.getSources(); + String imageUrl = !imageUrls.isEmpty() ? imageUrls.get(0) : null; + + values.put(APODContract.Columns.DESCRIPTION_IMAGE_URL, imageUrl); + values.put(APODContract.Columns.DESCRIPTION_TEXT, strippedText); + + return values; + } + }; + + private static class ExtractImageGetter implements Html.ImageGetter { + private final ArrayList mSources = new ArrayList(); + + @Override + public Drawable getDrawable(String source) { + mSources.add(source); + + // Dummy drawable. + return new ColorDrawable(Color.TRANSPARENT); + } + + public List getSources() { + return mSources; + } + } + + private static class RssParser { + private final XmlPullParser mParser; + + public RssParser(XmlPullParser parser) { + mParser = parser; + } + + public List parse() throws IOException, XmlPullParserException { + ArrayList items = new ArrayList(); + + mParser.nextTag(); + mParser.require(XmlPullParser.START_TAG, null, "rss"); + mParser.nextTag(); + mParser.require(XmlPullParser.START_TAG, null, "channel"); + + while (mParser.next() != XmlPullParser.END_TAG) { + if (mParser.getEventType() != XmlPullParser.START_TAG) { + continue; + } + + String name = mParser.getName(); + if (name.equals("item")) { + items.add(readItem()); + } else { + skip(); + } + } + + return items; + } + + private RssItem readItem() throws XmlPullParserException, IOException { + mParser.require(XmlPullParser.START_TAG, null, "item"); + RssItem item = new RssItem(); + while (mParser.next() != XmlPullParser.END_TAG) { + if (mParser.getEventType() != XmlPullParser.START_TAG) { + continue; + } + String name = mParser.getName(); + if (name.equals("title")) { + item.title = readTextFromTag("title"); + } else if (name.equals("description")) { + item.description = readTextFromTag("description"); + } else { + skip(); + } + } + return item; + } + + private String readTextFromTag(String tagName) throws IOException, XmlPullParserException { + mParser.require(XmlPullParser.START_TAG, null, tagName); + String text = readText(); + mParser.require(XmlPullParser.END_TAG, null, tagName); + return text; + } + + private String readText() throws IOException, XmlPullParserException { + String result = ""; + if (mParser.next() == XmlPullParser.TEXT) { + result = mParser.getText(); + mParser.nextTag(); + } + return result; + } + + private void skip() throws IOException, XmlPullParserException { + if (mParser.getEventType() != XmlPullParser.START_TAG) { + throw new IllegalStateException(); + } + int depth = 1; + while (depth != 0) { + switch (mParser.next()) { + case XmlPullParser.END_TAG: + depth--; + break; + case XmlPullParser.START_TAG: + depth++; + break; + } + } + } + } + + private static class RssItem { + public String title; + public String description; + } +} diff --git a/stetho-sample/src/main/java/com/facebook/stetho/sample/Constants.java b/stetho-sample/src/main/java/com/facebook/stetho/sample/Constants.java new file mode 100644 index 00000000..54b27a9e --- /dev/null +++ b/stetho-sample/src/main/java/com/facebook/stetho/sample/Constants.java @@ -0,0 +1,7 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.stetho.sample; + +public class Constants { + public static final String TAG = "StethoSample"; +} diff --git a/stetho-sample/src/main/java/com/facebook/stetho/sample/HelloWorldDumperPlugin.java b/stetho-sample/src/main/java/com/facebook/stetho/sample/HelloWorldDumperPlugin.java new file mode 100644 index 00000000..3d0b5ea0 --- /dev/null +++ b/stetho-sample/src/main/java/com/facebook/stetho/sample/HelloWorldDumperPlugin.java @@ -0,0 +1,49 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.stetho.sample; + +import java.io.PrintStream; +import java.util.Iterator; + +import android.text.TextUtils; + +import com.facebook.stetho.dumpapp.DumpException; +import com.facebook.stetho.dumpapp.DumpUsageException; +import com.facebook.stetho.dumpapp.DumperContext; +import com.facebook.stetho.dumpapp.DumperPlugin; + +public class HelloWorldDumperPlugin implements DumperPlugin { + private static final String NAME = "hello"; + + @Override + public String getName() { + return NAME; + } + + @Override + public void dump(DumperContext dumpContext) throws DumpException { + PrintStream writer = dumpContext.getStdout(); + Iterator args = dumpContext.getArgsAsList().iterator(); + + String helloToWhom = args.hasNext() ? args.next() : null; + if (helloToWhom != null) { + doHello(writer, helloToWhom); + } else { + doUsage(writer); + } + } + + private void doHello(PrintStream writer, String name) throws DumpUsageException { + if (TextUtils.isEmpty(name)) { + // This will print an error to the dumpapp user and cause a non-zero exit of the + // script. + throw new DumpUsageException("Name is empty"); + } + + writer.println("Hello " + name + "!"); + } + + private void doUsage(PrintStream writer) { + writer.println("Usage: dumpapp " + NAME + " "); + } +} diff --git a/stetho-sample/src/main/java/com/facebook/stetho/sample/MainActivity.java b/stetho-sample/src/main/java/com/facebook/stetho/sample/MainActivity.java new file mode 100644 index 00000000..a46ca4dc --- /dev/null +++ b/stetho-sample/src/main/java/com/facebook/stetho/sample/MainActivity.java @@ -0,0 +1,64 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.stetho.sample; + +import java.io.IOException; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.util.Log; +import android.view.View; +import android.widget.Toast; + +public class MainActivity extends Activity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.main_activity); + + findViewById(R.id.settings_btn).setOnClickListener(mMainButtonClicked); + findViewById(R.id.apod_btn).setOnClickListener(mMainButtonClicked); + } + + @Override + protected void onResume() { + super.onResume(); + getPrefs().registerOnSharedPreferenceChangeListener(mToastingPrefListener); + } + + @Override + protected void onPause() { + super.onPause(); + getPrefs().unregisterOnSharedPreferenceChangeListener(mToastingPrefListener); + } + + private SharedPreferences getPrefs() { + return PreferenceManager.getDefaultSharedPreferences(this /* context */); + } + + private final View.OnClickListener mMainButtonClicked = new View.OnClickListener() { + @Override + public void onClick(View v) { + int id = v.getId(); + if (id == R.id.settings_btn) { + SettingsActivity.show(MainActivity.this); + } else if (id == R.id.apod_btn) { + APODActivity.show(MainActivity.this); + } + } + }; + + private final SharedPreferences.OnSharedPreferenceChangeListener mToastingPrefListener = + new SharedPreferences.OnSharedPreferenceChangeListener() { + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + Object value = sharedPreferences.getAll().get(key); + Toast.makeText( + MainActivity.this, + getString(R.string.pref_change_message, key, value), + Toast.LENGTH_SHORT).show(); + } + }; +} diff --git a/stetho-sample/src/main/java/com/facebook/stetho/sample/Networker.java b/stetho-sample/src/main/java/com/facebook/stetho/sample/Networker.java new file mode 100644 index 00000000..b4164f4d --- /dev/null +++ b/stetho-sample/src/main/java/com/facebook/stetho/sample/Networker.java @@ -0,0 +1,368 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.stetho.sample; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import android.util.Pair; + +import com.facebook.stetho.inspector.network.DefaultResponseHandler; +import com.facebook.stetho.inspector.network.NetworkEventReporter; +import com.facebook.stetho.inspector.network.NetworkEventReporterImpl; + +/** + * Very simple centralized network middleware for illustration purposes. + */ +public class Networker { + private static Networker sInstance; + + private final Executor sExecutor = Executors.newFixedThreadPool(4); + private final NetworkEventReporter mStethoHook = NetworkEventReporterImpl.get(); + private final AtomicInteger mSequenceNumberGenerator = new AtomicInteger(0); + + private static final int READ_TIMEOUT_MS = 10000; + private static final int CONNECT_TIMEOUT_MS = 15000; + + public static synchronized Networker get() { + if (sInstance == null) { + sInstance = new Networker(); + } + return sInstance; + } + + private Networker() { + } + + public void submit(HttpRequest request, Callback callback) { + request.uniqueId = String.valueOf(mSequenceNumberGenerator.getAndIncrement()); + sExecutor.execute(new HttpRequestTask(request, callback)); + } + + private class HttpRequestTask implements Runnable { + private final HttpRequest request; + private final Callback callback; + + public HttpRequestTask(HttpRequest request, Callback callback) { + this.request = request; + this.callback = callback; + } + + @Override + public void run() { + try { + HttpResponse response = doFetch(); + callback.onResponse(response); + } catch (IOException e) { + callback.onFailure(e); + } + } + + private HttpResponse doFetch() throws IOException { + if (mStethoHook.isEnabled()) { + mStethoHook.requestWillBeSent( + new SimpleInspectorRequest(request)); + } + + HttpURLConnection conn = openConnectionAndSendRequest(); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + InputStream responseStream = conn.getInputStream(); + + if (mStethoHook.isEnabled()) { + responseStream = mStethoHook.interpretResponseStream( + request.uniqueId, + conn.getHeaderField("Content-Type"), + responseStream, + new DefaultResponseHandler(mStethoHook, request.uniqueId)); + } + + if (responseStream != null) { + copy(responseStream, out, new byte[1024]); + } + return new HttpResponse(conn.getResponseCode(), out.toByteArray()); + } + + private HttpURLConnection openConnectionAndSendRequest() throws IOException { + try { + URL url = new URL(request.url); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setRequestMethod(request.method.toString()); + if (request.method == HttpMethod.POST) { + conn.setDoOutput(true); + conn.getOutputStream().write(request.body); + } + + conn.connect(); + if (mStethoHook.isEnabled()) { + // Technically we should add headers here as well. + int requestSize = request.body != null ? request.body.length : 0; + mStethoHook.dataSent( + request.uniqueId, + requestSize, + requestSize); + mStethoHook.responseHeadersReceived(new SimpleInspectorResponse(request, conn)); + } + return conn; + } catch (IOException ex) { + if (mStethoHook.isEnabled()) { + mStethoHook.httpExchangeFailed(request.uniqueId, ex.toString()); + } + throw ex; + } + } + } + + private static void copy(InputStream in, OutputStream out, byte[] buf) throws IOException { + if (in == null) { + return; + } + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + } + + + public static class HttpRequest { + public final String friendlyName; + public final HttpMethod method; + public final String url; + public final byte[] body; + public final ArrayList> headers; + + String uniqueId; + + public static Builder newBuilder() { + return new Builder(); + } + + HttpRequest(Builder b) { + if (b.method == HttpMethod.POST) { + if (b.body == null) { + throw new IllegalArgumentException("POST must have a body"); + } + } else if (b.method == HttpMethod.GET) { + if (b.body != null) { + throw new IllegalArgumentException("GET cannot have a body"); + } + } + this.friendlyName = b.friendlyName; + this.method = b.method; + this.url = b.url; + this.body = b.body; + this.headers = b.headers; + } + + public static class Builder { + private String friendlyName; + private Networker.HttpMethod method; + private String url; + private byte[] body = null; + private ArrayList> headers = new ArrayList>(); + + Builder() { + } + + public Builder friendlyName(String friendlyName) { + this.friendlyName = friendlyName; + return this; + } + + public Builder method(Networker.HttpMethod method) { + this.method = method; + return this; + } + + public Builder url(String url) { + this.url = url; + return this; + } + + public Builder body(byte[] body) { + this.body = body; + return this; + } + + public Builder addHeader(String name, String value) { + this.headers.add(Pair.create(name, value)); + return this; + } + + public HttpRequest build() { + return new HttpRequest(this); + } + } + } + + public static enum HttpMethod { + GET, POST + } + + public static class HttpResponse { + public final int statusCode; + public final byte[] body; + + HttpResponse(int statusCode, byte[] body) { + this.statusCode = statusCode; + this.body = body; + } + } + + public interface Callback { + public void onResponse(HttpResponse result); + public void onFailure(IOException e); + } + + private static class SimpleInspectorRequest + extends SimpleInspectorHeaders + implements NetworkEventReporter.InspectorRequest { + private final HttpRequest request; + + public SimpleInspectorRequest(HttpRequest request) { + super(request.headers); + this.request = request; + } + + @Override + public String id() { + return request.uniqueId; + } + + @Override + public String friendlyName() { + return request.friendlyName; + } + + @Override + public Integer friendlyNameExtra() { + return null; + } + + @Override + public String url() { + return request.url; + } + + @Override + public String method() { + return request.method.toString(); + } + + @Override + public byte[] body() throws IOException { + return request.body; + } + } + + private static class SimpleInspectorResponse + extends SimpleInspectorHeaders + implements NetworkEventReporter.InspectorResponse { + private final HttpRequest request; + private final int statusCode; + private final String statusMessage; + + public SimpleInspectorResponse(HttpRequest request, HttpURLConnection conn) throws IOException { + super(convertHeaders(conn.getHeaderFields())); + this.request = request; + statusCode = conn.getResponseCode(); + statusMessage = conn.getResponseMessage(); + } + + private static ArrayList> convertHeaders(Map> map) { + ArrayList> array = new ArrayList>(); + for (Map.Entry> mapEntry : map.entrySet()) { + for (String mapEntryValue : mapEntry.getValue()) { + // HttpURLConnection puts a weird null entry in the header map that corresponds to + // the HTTP response line (for instance, HTTP/1.1 200 OK). Ignore that weirdness... + if (mapEntry.getKey() != null) { + array.add(Pair.create(mapEntry.getKey(), mapEntryValue)); + } + } + } + return array; + } + + @Override + public String requestId() { + return request.uniqueId; + } + + @Override + public String url() { + return request.url; + } + + @Override + public int statusCode() { + return statusCode; + } + + @Override + public String reasonPhrase() { + return statusMessage; + } + + @Override + public boolean connectionReused() { + // No idea... + return false; + } + + @Override + public int connectionId() { + return request.uniqueId.hashCode(); + } + + @Override + public boolean fromDiskCache() { + return false; + } + } + + private static class SimpleInspectorHeaders implements NetworkEventReporter.InspectorHeaders { + private final ArrayList> headers; + + public SimpleInspectorHeaders(ArrayList> headers) { + this.headers = headers; + } + + @Override + public int headerCount() { + return headers.size(); + } + + @Override + public String headerName(int index) { + return headers.get(index).first; + } + + @Override + public String headerValue(int index) { + return headers.get(index).second; + } + + @Override + public String firstHeaderValue(String name) { + int N = headerCount(); + for (int i = 0; i < N; i++) { + if (name.equals(headerName(i))) { + return headerValue(i); + } + } + return null; + } + } +} diff --git a/stetho-sample/src/main/java/com/facebook/stetho/sample/SampleApplication.java b/stetho-sample/src/main/java/com/facebook/stetho/sample/SampleApplication.java new file mode 100644 index 00000000..f03b1e87 --- /dev/null +++ b/stetho-sample/src/main/java/com/facebook/stetho/sample/SampleApplication.java @@ -0,0 +1,42 @@ +package com.facebook.stetho.sample; + +import java.util.ArrayList; + +import android.app.Application; +import android.content.Context; + +import com.facebook.stetho.DumperPluginsProvider; +import com.facebook.stetho.Stetho; +import com.facebook.stetho.dumpapp.DumperPlugin; + +public class SampleApplication extends Application { + @Override + public void onCreate() { + super.onCreate(); + + final Context context = this; + Stetho.initialize( + Stetho.newInitializerBuilder(context) + .enableDumpapp(new SampleDumperPluginsProvider(context)) + .enableWebKitInspector(Stetho.defaultInspectorModulesProvider(context)) + .build()); + } + + private static class SampleDumperPluginsProvider implements DumperPluginsProvider { + private final Context mContext; + + public SampleDumperPluginsProvider(Context context) { + mContext = context; + } + + @Override + public Iterable get() { + ArrayList plugins = new ArrayList(); + for (DumperPlugin defaultPlugin : Stetho.defaultDumperPluginsProvider(mContext).get()) { + plugins.add(defaultPlugin); + } + plugins.add(new HelloWorldDumperPlugin()); + return plugins; + } + } +} diff --git a/stetho-sample/src/main/java/com/facebook/stetho/sample/SettingsActivity.java b/stetho-sample/src/main/java/com/facebook/stetho/sample/SettingsActivity.java new file mode 100644 index 00000000..46e39af1 --- /dev/null +++ b/stetho-sample/src/main/java/com/facebook/stetho/sample/SettingsActivity.java @@ -0,0 +1,23 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.stetho.sample; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.preference.PreferenceActivity; + +public class SettingsActivity extends PreferenceActivity { + public static void show(Context context) { + context.startActivity(new Intent(context, SettingsActivity.class)); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Trying to avoid a dependency on the support library and go all the way back to Gingerbread, + // so we can't rely on the fragment-based preferences and must use the old deprecated methods. + addPreferencesFromResource(R.xml.settings); + } +} diff --git a/stetho-sample/src/main/res/layout/apod_list_item.xml b/stetho-sample/src/main/res/layout/apod_list_item.xml new file mode 100644 index 00000000..af83f261 --- /dev/null +++ b/stetho-sample/src/main/res/layout/apod_list_item.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/stetho-sample/src/main/res/layout/main_activity.xml b/stetho-sample/src/main/res/layout/main_activity.xml new file mode 100644 index 00000000..16ee1297 --- /dev/null +++ b/stetho-sample/src/main/res/layout/main_activity.xml @@ -0,0 +1,25 @@ + + + +