View Javadoc
1   /*
2    * Copyright (C) 2011 Lorenzo Villani
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *      http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  package com.simpligility.maven.plugins.android.standalonemojos;
17  
18  import com.android.ddmlib.AdbCommandRejectedException;
19  import com.android.ddmlib.AndroidDebugBridge;
20  import com.android.ddmlib.CollectingOutputReceiver;
21  import com.android.ddmlib.IDevice;
22  import com.android.ddmlib.ShellCommandUnresponsiveException;
23  import com.android.ddmlib.TimeoutException;
24  import com.simpligility.maven.plugins.android.AbstractAndroidMojo;
25  import com.simpligility.maven.plugins.android.DeviceCallback;
26  import com.simpligility.maven.plugins.android.common.DeviceHelper;
27  import com.simpligility.maven.plugins.android.config.ConfigHandler;
28  import com.simpligility.maven.plugins.android.config.ConfigPojo;
29  import com.simpligility.maven.plugins.android.config.PullParameter;
30  import com.simpligility.maven.plugins.android.configuration.Run;
31  
32  import org.apache.maven.plugin.MojoExecutionException;
33  import org.apache.maven.plugin.MojoFailureException;
34  import org.apache.maven.plugins.annotations.Mojo;
35  import org.apache.maven.plugins.annotations.Parameter;
36  import org.w3c.dom.Document;
37  import org.w3c.dom.NodeList;
38  import org.xml.sax.SAXException;
39  
40  import javax.xml.parsers.DocumentBuilder;
41  import javax.xml.parsers.DocumentBuilderFactory;
42  import javax.xml.parsers.ParserConfigurationException;
43  import javax.xml.xpath.XPath;
44  import javax.xml.xpath.XPathConstants;
45  import javax.xml.xpath.XPathExpression;
46  import javax.xml.xpath.XPathExpressionException;
47  import javax.xml.xpath.XPathFactory;
48  
49  import java.io.IOException;
50  
51  import static com.simpligility.maven.plugins.android.common.AndroidExtension.APK;
52  
53  import java.io.BufferedReader;
54  import java.io.StringReader;
55  import java.lang.reflect.InvocationTargetException;
56  import java.lang.reflect.Method;
57  import java.net.InetSocketAddress;
58  
59  /**
60   * Runs the first Activity shown in the top-level launcher as determined by its Intent filters.
61   * <p>
62   * Android provides a component-based architecture, which means that there is no "main" function which serves as an
63   * entry point to the APK. There's an homogeneous collection of Activity(es), Service(s), Receiver(s), etc.
64   * </p>
65   * <p>
66   * The Android top-level launcher (whose purpose is to allow users to launch other applications) uses the Intent
67   * resolution mechanism to determine which Activity(es) to show to the end user. Such activities are identified by at
68   * least:
69   * <ul>
70   * <li>Action type: <code>android.intent.action.MAIN</code></li>
71   * <li>Category: <code>android.intent.category.LAUNCHER</code></li>
72   * </ul>
73   * </p>
74   * <p>And are declared in <code>AndroidManifest.xml</code> as such:</p>
75   * <pre>
76   * &lt;activity android:name=".ExampleActivity"&gt;
77   *     &lt;intent-filter&gt;
78   *         &lt;action android:name="android.intent.action.MAIN" /&gt;
79   *         &lt;category android:name="android.intent.category.LAUNCHER" /&gt;
80   *     &lt;/intent-filter&gt;
81   * &lt;/activity&gt;
82   * </pre>
83   * <p>
84   * This {@link Mojo} will try to to launch the first activity of this kind found in <code>AndroidManifest.xml</code>. In
85   * case multiple activities satisfy the requirements listed above only the first declared one is run. In case there are
86   * no "Launcher activities" declared in the manifest or no activities declared at all, this goal aborts throwing an
87   * error.
88   * </p>
89   * <p>
90   * The device parameter is taken into consideration so potentially the Activity found is started on all attached
91   * devices. The application will NOT be deployed and running will silently fail if the application is not deployed.
92   * </p>
93   *
94   * @author Lorenzo Villani - lorenzo@villani.me
95   * @author Manfred Moser - manfred@simpligility.com
96   * @see "http://developer.android.com/guide/topics/fundamentals.html"
97   * @see "http://developer.android.com/guide/topics/intents/intents-filters.html"
98   */
99  @Mojo( name = "run" )
100 public class RunMojo extends AbstractAndroidMojo
101 {
102 
103     /**
104      * <p>The configuration for the run goal can be set up in the plugin configuration in the pom file as:</p>
105      * <pre>
106      * &lt;run&gt;
107      *     &lt;debug&gt;true|false|portnumber&lt;/debug&gt;
108      * &lt;/run&gt;
109      * </pre>
110      * <p>The <code>&lt;debug&gt;</code> parameter is optional and defaults to false. Numeric values like 5432 are 
111      * parsed as port number. 
112      * <p>The debug parameter can also be configured as property in the pom or settings file
113      * <pre>
114      * &lt;properties&gt;
115      *     &lt;android.run.debug&gt;true&lt;/android.run.debug&gt;
116      * &lt;/properties&gt;
117      * </pre>
118      * or from command-line with parameter <code>-Dandroid.run.debug=true</code>.</p>
119      */
120     @Parameter
121     @ConfigPojo
122     private Run run;
123 
124     /**
125      * Debug parameter for the the run goal. If true, the device or emulator will pause execution of the process at
126      * startup to wait for a debugger to connect. Also see the "run" parameter documentation. Default value is false.
127      * If the value is numeric, it is treated as a port number to forward the JDWP protocol
128      * of the launched process to.
129      */
130     @Parameter( property = "android.run.debug" )
131     protected String runDebug;
132 
133     /* the value for the debug flag after parsing pom and parameter */
134     @PullParameter( defaultValue = "false" )
135     private String parsedDebug;
136 
137     /**
138      * Holds information about the "Launcher" activity.
139      *
140      * @author Lorenzo Villani
141      */
142     private static class LauncherInfo
143     {
144         private String packageName;
145 
146         private String activity;
147 
148         public String getPackageName()
149         {
150             return packageName;
151         }
152 
153         public void setPackageName( String packageName )
154         {
155             this.packageName = packageName;
156         }
157 
158         public String getActivity()
159         {
160             return activity;
161         }
162 
163         public void setActivity( String activity )
164         {
165             this.activity = activity;
166         }
167     }
168 
169     // ----------------------------------------------------------------------
170     // Public methods
171     // ----------------------------------------------------------------------
172 
173     /**
174      * {@inheritDoc}
175      */
176     @Override
177     public void execute() throws MojoExecutionException, MojoFailureException
178     {
179         if ( project.getPackaging().equals( APK ) )
180         {
181             try
182             {
183                 LauncherInfo launcherInfo;
184 
185                 launcherInfo = getLauncherActivity();
186 
187                 ConfigHandler configHandler = new ConfigHandler( this, this.session, this.execution );
188                 configHandler.parseConfiguration();
189 
190                 launch( launcherInfo );
191             }
192             catch ( Exception ex )
193             {
194                 getLog().info( "Unable to run launcher Activity" );
195                 getLog().debug( ex );
196             }
197         }
198         else
199         {
200             getLog().info( "Project packaging is not apk, skipping run action." );
201         }
202     }
203 
204     // ----------------------------------------------------------------------
205     // Private methods
206     // ----------------------------------------------------------------------
207 
208     /**
209      * Gets the first "Launcher" Activity by running an XPath query on <code>AndroidManifest.xml</code>.
210      *
211      * @return A {@link LauncherInfo}
212      * @throws MojoFailureException
213      * @throws ParserConfigurationException
214      * @throws IOException
215      * @throws SAXException
216      * @throws XPathExpressionException
217      */
218     private LauncherInfo getLauncherActivity()
219             throws ParserConfigurationException, SAXException, IOException, XPathExpressionException,
220             MojoFailureException
221     {
222         Document document;
223         DocumentBuilder documentBuilder;
224         DocumentBuilderFactory documentBuilderFactory;
225         Object result;
226         XPath xPath;
227         XPathExpression xPathExpression;
228         XPathFactory xPathFactory;
229 
230         //
231         // Setup JAXP stuff
232         //
233         documentBuilderFactory = DocumentBuilderFactory.newInstance();
234 
235         documentBuilder = documentBuilderFactory.newDocumentBuilder();
236 
237         document = documentBuilder.parse( destinationManifestFile );
238 
239         xPathFactory = XPathFactory.newInstance();
240 
241         xPath = xPathFactory.newXPath();
242 
243         xPathExpression = xPath.compile(
244                 "//manifest/application/activity/intent-filter[action[@name=\"android.intent.action.MAIN\"] "
245                 + "and category[@name=\"android.intent.category.LAUNCHER\"]]/.." );
246 
247         //
248         // Run XPath query
249         //
250         result = xPathExpression.evaluate( document, XPathConstants.NODESET );
251 
252         if ( result instanceof NodeList )
253         {
254             NodeList activities;
255 
256             activities = ( NodeList ) result;
257 
258             if ( activities.getLength() > 0 )
259             {
260                 // Grab the first declared Activity
261                 LauncherInfo launcherInfo;
262 
263                 launcherInfo = new LauncherInfo();
264                 String activityName = activities.item( 0 ).getAttributes().getNamedItem( "android:name" )
265                         .getNodeValue();
266 
267                 if ( ! activityName.contains( "." ) )
268                 {
269                     activityName = "." + activityName;
270                 }
271 
272                 if ( activityName.startsWith( "." ) )
273                 {
274                     String packageName = document.getElementsByTagName( "manifest" ).item( 0 ).getAttributes()
275                             .getNamedItem( "package" ).getNodeValue();
276                     activityName = packageName + activityName;
277                 }
278 
279                 launcherInfo.activity = activityName;
280 
281                 launcherInfo.packageName = renameManifestPackage != null
282                     ? renameManifestPackage
283                     : document.getDocumentElement().getAttribute( "package" ).toString();
284 
285                 return launcherInfo;
286             }
287             else
288             {
289                 // If we get here, we couldn't find a launcher activity.
290                 throw new MojoFailureException( "Could not find a launcher activity in manifest" );
291             }
292         }
293         else
294         {
295             // If we get here we couldn't find any Activity
296             throw new MojoFailureException( "Could not find any activity in manifest" );
297         }
298     }
299 
300     /**
301      * Executes the "Launcher activity".
302      *
303      * @param info A {@link LauncherInfo}.
304      * @throws MojoFailureException
305      * @throws MojoExecutionException
306      */
307     private void launch( final LauncherInfo info ) throws MojoExecutionException, MojoFailureException
308     {
309         final String command;
310         
311         final int debugPort = findDebugPort();
312 
313         command = String.format( "am start %s-n %s/%s", debugPort >= 0 ? "-D " : "", info.packageName, info.activity );
314 
315         doWithDevices( new DeviceCallback()
316         {
317             @Override
318             public void doWithDevice( IDevice device ) throws MojoExecutionException, MojoFailureException
319             {
320                 String deviceLogLinePrefix = DeviceHelper.getDeviceLogLinePrefix( device );
321 
322                 try
323                 {
324                     getLog().info( deviceLogLinePrefix + "Attempting to start " + info.packageName + "/" 
325                             + info.activity );
326 
327                     CollectingOutputReceiver shellOutput = new CollectingOutputReceiver();
328                     device.executeShellCommand( command, shellOutput );
329                     if ( shellOutput.getOutput().contains( "Error" ) )
330                     {
331                         throw new MojoFailureException( shellOutput.getOutput() );
332                     }
333                     if ( debugPort > 0 ) 
334                     {
335                         int pid = findPid( device, "ps" );
336                         if ( pid == -1 )
337                         {
338                             pid = findPid( device, "ps -Af" );
339                         }
340                         if ( pid == -1 )
341                         {
342                             throw new MojoFailureException( "Cannot find stated process " + info.packageName );
343                         }
344                         getLog().info(
345                             deviceLogLinePrefix + "Process " + debugPort + " launched"
346                         );
347                         try 
348                         {
349                             createForward( device, debugPort, pid );
350                             getLog().info(
351                                 deviceLogLinePrefix + "Debugger listening on " + debugPort
352                             );
353                         } 
354                         catch ( Exception ex ) 
355                         {
356                             throw new MojoFailureException( 
357                                 "Cannot create forward tcp: " + debugPort 
358                                     + " jdwp: " + pid, ex 
359                             );
360                         }
361                     }
362                 }
363                 catch ( IOException ex )
364                 {
365                     throw new MojoFailureException( deviceLogLinePrefix + "Input/Output error", ex );
366                 }
367                 catch ( TimeoutException ex )
368                 {
369                     throw new MojoFailureException( deviceLogLinePrefix + "Command timeout", ex );
370                 }
371                 catch ( AdbCommandRejectedException ex )
372                 {
373                     throw new MojoFailureException( deviceLogLinePrefix + "ADB rejected the command", ex );
374                 }
375                 catch ( ShellCommandUnresponsiveException ex )
376                 {
377                     throw new MojoFailureException( deviceLogLinePrefix + "Unresponsive command", ex );
378                 }
379             }
380 
381             private int findPid( IDevice device, final String cmd )
382             throws IOException, TimeoutException, AdbCommandRejectedException, ShellCommandUnresponsiveException
383             {
384                 CollectingOutputReceiver processOutput = new CollectingOutputReceiver();
385                 device.executeShellCommand( cmd, processOutput );
386                 BufferedReader r = new BufferedReader( new StringReader( processOutput.getOutput() ) );
387                 int pid = -1;
388                 for ( ;; )
389                 {
390                     String line = r.readLine();
391                     if ( line == null )
392                     {
393                         break;
394                     }
395                     if ( line.endsWith( info.packageName ) )
396                     {
397                         String[] values = line.split( " +" );
398                         if ( values.length > 2 )
399                         {
400                             pid = Integer.valueOf( values[1] );
401                             break;
402                         }
403                     }
404                 }
405                 r.close();
406                 return pid;
407             }
408         } );
409     }
410     
411     private static void createForward( IDevice device, int debugPort, int pid ) 
412     throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException
413     {
414         Method m = Class.forName( "com.android.ddmlib.AdbHelper" ).
415             getDeclaredMethod(
416                 "createForward", InetSocketAddress.class, 
417                 device.getClass(), String.class, String.class
418             );
419         m.setAccessible( true );
420         m.invoke(
421             null, AndroidDebugBridge.getSocketAddress(), device,
422             String.format( "tcp:%d", debugPort ), String.format( "jdwp:%d", pid )
423         );
424     }
425 
426     private int findDebugPort()
427     {
428         int debugPort;
429         if ( "true".equals( parsedDebug ) )
430         {
431             debugPort = 0;
432         }
433         else
434         {
435             try
436             {
437                 debugPort = Integer.parseInt( parsedDebug );
438             }
439             catch ( NumberFormatException ex )
440             {
441                 debugPort = -1;
442             }
443         }
444         return debugPort;
445     }
446 }