devtools/madb: default build/install behavior for "start" and "install" commands

"madb install" now builds the project before installing. If the user
wants to skip the build step, "-build" flag can be set to false.

"madb start" now builds the project, and then installs the app to all
the devices before launching the app. The app is newly installed only
if one or more of the following conditions are met:
 - the app is not found on the device
 - the app is outdated (TODO)
 - "-force-install" flag is set

This means that running "madb start" from any Gradle Android project
directory would do the right thing in most cases. Users can still
override the default behavior by configuring the "-build" and
"-force-install" flags.

Change-Id: I86d7002bfd15aef4a15e57bea0991fbdda44fc37
diff --git a/madb/doc.go b/madb/doc.go
index e287c42..31e2c8f 100644
--- a/madb/doc.go
+++ b/madb/doc.go
@@ -126,24 +126,29 @@
 
 Installs your app on all devices.
 
+If the working directory contains a Gradle Android project (i.e., has
+"build.gradle"), this command will first run a small Gradle script to extract
+the variant properties, which will be used to find the best matching .apk for
+each device. These extracted properties are cached, and "madb install" can be
+repeated without running this Gradle script again. The properties can be
+re-extracted by clearing the cache by providing "-clear-cache" flag.
+
+Once the variant properties are extracted, the best matching .apk for each
+device will be installed in parallel.
+
+This command is similar to running "gradlew :<moduleName>:<variantName>Install",
+but "madb install" is more flexible: 1) you can install the app to a subset of
+the devices, and 2) the app is installed concurrently, which saves a lot of
+time.
+
+If the working directory contains a Flutter project (i.e., has "flutter.yaml"),
+this command will run "flutter install --device-id <device serial>" for all
+devices.
+
 To install your app for a specific user on a particular device, use 'madb user
 set' command to set the default user ID for that device. (See 'madb help user'
 for more details.)
 
-If the working directory contains a Gradle Android project (i.e., has
-"build.gradle"), this command will run a small Gradle script to extract the
-variant properties, which will be used to find the best matching .apk for each
-device.
-
-In this case, the extracted properties are cached, so that "madb install" can be
-repeated without even running the Gradle script again. The IDs can be
-re-extracted by clearing the cache by providing "-clear-cache" flag.
-
-This command is similar to running "gradlew :<moduleName>:<variantName>Install",
-but the gradle command is limited in that 1) it always installs the app to all
-connected devices, and 2) it installs the app on one device at a time
-sequentially.
-
 To install a specific .apk file to all devices, use "madb exec install
 <path_to_apk>" instead.
 
@@ -151,6 +156,8 @@
    madb install [flags]
 
 The madb install flags are:
+ -build=true
+   Build the target app variant before installing or running the app.
  -clear-cache=false
    Clear the cache and re-extract the variant properties such as the application
    ID and the main activity name. Only takes effect when no arguments are
@@ -160,9 +167,6 @@
    top level Gradle project containing multiple sub-modules. When not specified,
    the first available application module is used. Only takes effect when no
    arguments are provided.
- -r=true
-   Replace the existing application. Same effect as the '-r' flag of 'adb
-   install' command.
  -variant=
    Specify which build variant to use. When not specified, the first available
    build variant is used. Only takes effect when no arguments are provided.
@@ -311,6 +315,20 @@
 
 Launches your app on all devices.
 
+In most cases, running "madb start" from an Android Gradle project directory
+will do the right thing for you. "madb start" will build the project first.
+After the project build is completed, this command will install the best
+matching .apk for each device, only if one or more of the following conditions
+are met:
+ - the app is not found on the device
+ - the installed app is outdated (determined by comparing the last update time of the
+   installed app and the last modification time of the local .apk file)
+ - "-force-install" flag is set
+
+If you would like to run the same version of the app repeatedly (e.g., for QA
+testing), you can explicitly turn off the build flag by providing "-build=false"
+to skip the build step.
+
 To run your app as a specific user on a particular device, use 'madb user set'
 command to set the default user ID for that device. (See 'madb help user' for
 more details.)
@@ -345,10 +363,14 @@
 "-clear-cache" flag.
 
 The madb start flags are:
+ -build=true
+   Build the target app variant before installing or running the app.
  -clear-cache=false
    Clear the cache and re-extract the variant properties such as the application
    ID and the main activity name. Only takes effect when no arguments are
    provided.
+ -force-install=false
+   Force install the target app before starting the activity.
  -force-stop=true
    Force stop the target app before starting the activity.
  -module=
@@ -494,6 +516,7 @@
 settings:
 
     madb clear-data
+    madb install
     madb start
     madb stop
     madb uninstall
diff --git a/madb/install.go b/madb/install.go
index 0e1e435..3d1c88d 100644
--- a/madb/install.go
+++ b/madb/install.go
@@ -6,6 +6,7 @@
 
 import (
 	"fmt"
+	"os"
 	"regexp"
 	"strconv"
 	"strings"
@@ -14,42 +15,78 @@
 	"v.io/x/lib/gosh"
 )
 
-var (
-	replaceFlag bool
-)
-
 func init() {
 	initializePropertyCacheFlags(&cmdMadbInstall.Flags)
-	cmdMadbInstall.Flags.BoolVar(&replaceFlag, "r", true, `Replace the existing application. Same effect as the '-r' flag of 'adb install' command.`)
+	initializeBuildFlags(&cmdMadbInstall.Flags)
 }
 
 var cmdMadbInstall = &cmdline.Command{
-	Runner: subCommandRunner{nil, runMadbInstallForDevice, true},
+	Runner: subCommandRunner{initMadbInstall, runMadbInstallForDevice, true},
 	Name:   "install",
 	Short:  "Install your app on all devices",
 	Long: `
 Installs your app on all devices.
 
+If the working directory contains a Gradle Android project (i.e., has "build.gradle"), this command
+will first run a small Gradle script to extract the variant properties, which will be used to find
+the best matching .apk for each device. These extracted properties are cached, and "madb install"
+can be repeated without running this Gradle script again. The properties can be re-extracted by
+clearing the cache by providing "-clear-cache" flag.
+
+Once the variant properties are extracted, the best matching .apk for each device will be installed
+in parallel.
+
+This command is similar to running "gradlew :<moduleName>:<variantName>Install", but "madb install"
+is more flexible: 1) you can install the app to a subset of the devices, and 2) the app is installed
+concurrently, which saves a lot of time.
+
+
+If the working directory contains a Flutter project (i.e., has "flutter.yaml"), this command will
+run "flutter install --device-id <device serial>" for all devices.
+
+
 To install your app for a specific user on a particular device, use 'madb user set' command to set
 the default user ID for that device. (See 'madb help user' for more details.)
 
-If the working directory contains a Gradle Android project (i.e., has "build.gradle"), this command
-will run a small Gradle script to extract the variant properties, which will be used to find the
-best matching .apk for each device.
-
-In this case, the extracted properties are cached, so that "madb install" can be repeated without
-even running the Gradle script again. The IDs can be re-extracted by clearing the cache by providing
-"-clear-cache" flag.
-
-This command is similar to running "gradlew :<moduleName>:<variantName>Install", but the gradle
-command is limited in that 1) it always installs the app to all connected devices, and 2) it
-installs the app on one device at a time sequentially.
-
 To install a specific .apk file to all devices, use "madb exec install <path_to_apk>" instead.
 `,
 }
 
+func initMadbInstall(env *cmdline.Env, args []string, properties variantProperties) ([]string, error) {
+	// If the "-build" flag is set, first run the relevant gradle tasks to build the .apk files
+	// before installing the app to the devices.
+	if isGradleProject(wd) && buildFlag {
+		sh := gosh.NewShell(nil)
+		defer sh.Cleanup()
+
+		// Show the output from Gradle, so that users can see what's going on.
+		sh.PropagateChildOutput = true
+		sh.ContinueOnError = true
+
+		wrapper, err := findGradleWrapper(wd)
+		if err != nil {
+			return nil, err
+		}
+
+		// Build the project by running ":<module>:assemble<Variant>" task.
+		cmdArgs := []string{"--daemon", properties.AssembleTask}
+		cmd := sh.Cmd(wrapper, cmdArgs...)
+		cmd.Run()
+
+		if err = sh.Err; err != nil {
+			return nil, fmt.Errorf("Failed to build the app: %v", err)
+		}
+	}
+
+	return args, nil
+}
+
 func runMadbInstallForDevice(env *cmdline.Env, args []string, d device, properties variantProperties) error {
+	// The user is executing "madb install" explicitly, and the installation should not be skipped.
+	return installVariantToDevice(d, properties, true)
+}
+
+func installVariantToDevice(d device, properties variantProperties, forceInstall bool) error {
 	sh := gosh.NewShell(nil)
 	defer sh.Cleanup()
 
@@ -72,17 +109,25 @@
 			return fmt.Errorf("Could not find the matching .apk for device %q", d.displayName())
 		}
 
-		// Run the install command.
-		cmdArgs := []string{"-s", d.Serial, "install"}
-		if replaceFlag {
-			cmdArgs = append(cmdArgs, "-r")
+		// Determine whether the app should be installed on the given device.
+		shouldInstall, err := shouldInstallVariant(d, properties, bestOutput, forceInstall)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "Warning: Could not determine whether the app should be installed on device %q. Attempting to install...", d.displayName())
+			shouldInstall = true
 		}
-		if d.UserID != "" {
-			cmdArgs = append(cmdArgs, "--user", d.UserID)
+
+		// Run the "adb install" command to perform the installation.
+		if shouldInstall {
+			cmdArgs := []string{"-s", d.Serial, "install", "-r"}
+			if d.UserID != "" {
+				cmdArgs = append(cmdArgs, "--user", d.UserID)
+			}
+			cmdArgs = append(cmdArgs, bestOutput.OutputFilePath)
+			cmd := sh.Cmd("adb", cmdArgs...)
+			return runGoshCommandForDevice(cmd, d, true)
 		}
-		cmdArgs = append(cmdArgs, bestOutput.OutputFilePath)
-		cmd := sh.Cmd("adb", cmdArgs...)
-		return runGoshCommandForDevice(cmd, d, true)
+
+		fmt.Printf("Device %q has the most recent version of the app already. Skipping the installation.\n", d.displayName())
 	}
 
 	if isFlutterProject(wd) {
@@ -94,6 +139,54 @@
 	return fmt.Errorf("Could not find the target app to be installed. Try running 'madb install' from a Gradle or Flutter project directory.")
 }
 
+// shouldInstallVariant determines whether the app should be installed on the given device or not.
+func shouldInstallVariant(d device, properties variantProperties, bestOutput *variantOutput, forceInstall bool) (bool, error) {
+	if forceInstall {
+		return true, nil
+	}
+
+	// Check if the app is installed on this device.
+	installed, err := isInstalled(d, properties)
+	if err != nil {
+		return false, err
+	}
+	if !installed {
+		return true, nil
+	}
+
+	// TODO(youngseokyoon): check the "lastUpdateTime" property of the installed app.
+	// For now, assume the app is outdated, and just return true to install.
+	return true, nil
+}
+
+// isInstalled determines whether the app variant is already installed on the given device.
+func isInstalled(d device, properties variantProperties) (bool, error) {
+	sh := gosh.NewShell(nil)
+	defer sh.Cleanup()
+
+	sh.ContinueOnError = true
+
+	// Run "adb shell pm list packages --user <user_id> <app_id>".
+	cmdArgs := []string{"-s", d.Serial, "shell", "pm", "list", "packages"}
+	if d.UserID != "" {
+		cmdArgs = append(cmdArgs, "--user", d.UserID)
+	}
+	cmdArgs = append(cmdArgs, properties.AppID)
+	cmd := sh.Cmd("adb", cmdArgs...)
+	output := cmd.Stdout()
+
+	if sh.Err != nil {
+		return false, sh.Err
+	}
+
+	// If the app is installed, the output should be in the form "package:<app_id>".
+	if strings.TrimSpace(output) == fmt.Sprintf("package:%v", properties.AppID) {
+		return true, nil
+	}
+
+	return false, nil
+}
+
 // getSupportedAbisForDevice returns all the abis supported by the given device.
 func getSupportedAbisForDevice(d device) ([]string, error) {
 	sh := gosh.NewShell(nil)
diff --git a/madb/madb.go b/madb/madb.go
index 64745fa..59e60fe 100644
--- a/madb/madb.go
+++ b/madb/madb.go
@@ -34,6 +34,8 @@
 	moduleFlag     string
 	variantFlag    string
 
+	buildFlag bool
+
 	wd string // working directory
 )
 
@@ -57,6 +59,11 @@
 	flags.StringVar(&variantFlag, "variant", "", `Specify which build variant to use. When not specified, the first available build variant is used. Only takes effect when no arguments are provided.`)
 }
 
+// initializeBuildFlags sets up the flags related to running Gradle build tasks.
+func initializeBuildFlags(flags *flag.FlagSet) {
+	flags.BoolVar(&buildFlag, "build", true, `Build the target app variant before installing or running the app.`)
+}
+
 var cmdMadb = &cmdline.Command{
 	Children: []*cmdline.Command{
 		cmdMadbClearData,
@@ -465,6 +472,10 @@
 }
 
 type variantProperties struct {
+	ProjectPath    string
+	VariantName    string
+	CleanTask      string
+	AssembleTask   string
 	AppID          string
 	Activity       string
 	AbiFilters     []string
diff --git a/madb/madb_init.gradle b/madb/madb_init.gradle
index 7d8ce0c..1c97a4a 100644
--- a/madb/madb_init.gradle
+++ b/madb/madb_init.gradle
@@ -24,12 +24,16 @@
     def targetVariant = getTargetVariant(project)
 
     // Collect the variant properties in a map, so that it can be printed out as a JSON.
-    def result = [:]
-    result['VariantName'] = targetVariant.name
-    result['AppID'] = getApplicationId(targetVariant)
-    result['Activity'] = getMainActivity(project)
-    result['AbiFilters'] = getAbiFilters(targetVariant)
-    result['VariantOutputs'] = getVariantOutputs(targetVariant)
+    def result = [
+        ProjectPath:    project.path,
+        VariantName:    targetVariant.name,
+        CleanTask:      project.path + ":clean",
+        AssembleTask:   targetVariant.assemble.path,
+        AppID:          getApplicationId(targetVariant),
+        Activity:       getMainActivity(project),
+        AbiFilters:     getAbiFilters(targetVariant),
+        VariantOutputs: getVariantOutputs(targetVariant)
+    ]
 
     // Format the resulting map into JSON and print it.
     def resultJson = JsonOutput.prettyPrint(JsonOutput.toJson(result))
diff --git a/madb/start.go b/madb/start.go
index 12bb2b3..71bb441 100644
--- a/madb/start.go
+++ b/madb/start.go
@@ -13,12 +13,15 @@
 )
 
 var (
-	forceStopFlag bool
+	forceStopFlag    bool
+	forceInstallFlag bool
 )
 
 func init() {
 	initializePropertyCacheFlags(&cmdMadbStart.Flags)
+	initializeBuildFlags(&cmdMadbStart.Flags)
 	cmdMadbStart.Flags.BoolVar(&forceStopFlag, "force-stop", true, `Force stop the target app before starting the activity.`)
+	cmdMadbStart.Flags.BoolVar(&forceInstallFlag, "force-install", false, `Force install the target app before starting the activity.`)
 }
 
 var cmdMadbStart = &cmdline.Command{
@@ -28,6 +31,18 @@
 	Long: `
 Launches your app on all devices.
 
+In most cases, running "madb start" from an Android Gradle project directory will do the right thing
+for you. "madb start" will build the project first. After the project build is completed, this
+command will install the best matching .apk for each device, only if one or more of the following
+conditions are met:
+ - the app is not found on the device
+ - the installed app is outdated (determined by comparing the last update time of the
+   installed app and the last modification time of the local .apk file)
+ - "-force-install" flag is set
+
+If you would like to run the same version of the app repeatedly (e.g., for QA testing), you can
+explicitly turn off the build flag by providing "-build=false" to skip the build step.
+
 To run your app as a specific user on a particular device, use 'madb user set' command to set the
 default user ID for that device. (See 'madb help user' for more details.)
 
@@ -59,10 +74,25 @@
 }
 
 func initMadbStart(env *cmdline.Env, args []string, properties variantProperties) ([]string, error) {
+	// If the "-build" flag is set, call the init function of the install command, which would run
+	// the relevant Gradle build tasks to build the project.
+	if buildFlag {
+		newArgs, err := initMadbInstall(env, args, properties)
+		if err != nil {
+			return nil, err
+		}
+		args = newArgs
+	}
+
 	return initMadbCommand(env, args, properties, true, true)
 }
 
 func runMadbStartForDevice(env *cmdline.Env, args []string, d device, properties variantProperties) error {
+	// If the "-build" flag is set, install the app first.
+	if err := installVariantToDevice(d, properties, forceInstallFlag); err != nil {
+		return err
+	}
+
 	sh := gosh.NewShell(nil)
 	defer sh.Cleanup()
 
@@ -71,14 +101,16 @@
 	if len(args) == 2 {
 		appID, activity := args[0], args[1]
 
-		// In case the activity name is a simple name (i.e. without the package name), add a dot in the front.
-		// This is a shorthand syntax to prepend the activity name with the package name provided in the manifest.
+		// In case the activity name is a simple name (i.e. without the package name), add a dot in
+		// the front. This is a shorthand syntax to prepend the activity name with the package name
+		// provided in the manifest.
 		// http://developer.android.com/guide/topics/manifest/activity-element.html#nm
 		if !strings.ContainsAny(activity, ".") {
 			activity = "." + activity
 		}
 
-		// More details on the "adb shell am" command can be found at: http://developer.android.com/tools/help/shell.html#am
+		// More details on the "adb shell am" command can be found at:
+		// http://developer.android.com/tools/help/shell.html#am
 		cmdArgs := []string{"-s", d.Serial, "shell", "am", "start"}
 		if forceStopFlag {
 			cmdArgs = append(cmdArgs, "-S")
diff --git a/madb/user.go b/madb/user.go
index 7f91084..94bd2fa 100644
--- a/madb/user.go
+++ b/madb/user.go
@@ -35,6 +35,7 @@
 Below is the list of madb commands which are affected by the default user ID settings:
 
     madb clear-data
+    madb install
     madb start
     madb stop
     madb uninstall