diff options
author | AoJ Architecture Team <aoj-architecture-team@google.com> | 2023-10-13 04:04:10 -0700 |
---|---|---|
committer | Copybara-Service <copybara-worker@google.com> | 2023-10-13 07:13:48 -0700 |
commit | 2d7142cc382b15b24ddc1797e40a3f05b18ed09d (patch) | |
tree | 0016f20e557ebc8211043f2343ce44d356af07cb | |
parent | d88f19ae25c9e587bb976af4f821f95492c5a78e (diff) | |
download | android_onboarding-2d7142cc382b15b24ddc1797e40a3f05b18ed09d.tar.gz |
external/android_onboarding: Android Onboarding ❤️ AOSP
PiperOrigin-RevId: 573173319
Change-Id: I2581fc3cca0df7794a1eaed0b367dbec10e8bebd
9 files changed, 243 insertions, 140 deletions
diff --git a/src/com/android/onboarding/contracts/ActivityUtil.kt b/src/com/android/onboarding/contracts/ActivityUtil.kt index 3b87c2f..426be93 100644 --- a/src/com/android/onboarding/contracts/ActivityUtil.kt +++ b/src/com/android/onboarding/contracts/ActivityUtil.kt @@ -27,5 +27,5 @@ val Activity.nodeId: Long /** Mark the executing node as failed with [reason]. */ fun Activity.failNode(reason: String? = null) { - AndroidOnboardingGraphLog.log(ActivityNodeFail(nodeId, this.javaClass, reason)) + AndroidOnboardingGraphLog.log(ActivityNodeFail(nodeId, reason)) } diff --git a/src/com/android/onboarding/contracts/IdentifyExecutingContract.kt b/src/com/android/onboarding/contracts/IdentifyExecutingContract.kt new file mode 100644 index 0000000..9a83f69 --- /dev/null +++ b/src/com/android/onboarding/contracts/IdentifyExecutingContract.kt @@ -0,0 +1,29 @@ +package com.android.onboarding.contracts + +import android.content.Intent + +/** + * Interface implemented by a [OnboardingActivityApiContract] to allow it to be used with + * [findExecutingContract]. + * + * This allows a single activity to implement multiple contracts, as long as those contracts + * can be distinguished based on properties of the [Intent]. + * + * Recommended usage: + * ``` + * val contracts = arrayOf(RedContract(), BlueContract(), GreenContract()) + * val contract = intent?.findExecutingContract(*contracts) ?: RedContract() + * ``` + */ +interface IdentifyExecutingContract { + /** Returns true if this [OnboardingActivityApiContract] is currently executing. */ + fun isExecuting(intent: Intent): Boolean +} + +/** + * Find which of multiple non-overlapping contracts are executing. + * + * See [IdentifyExecutingContract] for usage. + */ +fun <I : IdentifyExecutingContract> Intent.findExecutingContract(vararg contracts: I) = + contracts.firstOrNull { it.isExecuting(this) } diff --git a/src/com/android/onboarding/contracts/OnboardingActivityApiContract.kt b/src/com/android/onboarding/contracts/OnboardingActivityApiContract.kt index 147f613..f513fd1 100644 --- a/src/com/android/onboarding/contracts/OnboardingActivityApiContract.kt +++ b/src/com/android/onboarding/contracts/OnboardingActivityApiContract.kt @@ -20,6 +20,7 @@ import com.android.onboarding.nodes.OnboardingGraphLog.OnboardingEvent.ActivityN import com.android.onboarding.nodes.OnboardingGraphLog.OnboardingEvent.ActivityNodeSetResult import com.android.onboarding.nodes.OnboardingGraphLog.OnboardingEvent.ActivityNodeStartExecuteSynchronously import com.android.onboarding.nodes.OnboardingGraphLog.OnboardingEvent.ActivityNodeValidating +import com.google.errorprone.annotations.CanIgnoreReturnValue import java.util.UUID /** @@ -109,9 +110,7 @@ abstract class OnboardingActivityApiContract<I, O> : ActivityResultContract<I, O var contractResult = performSetResult(result) if (contractResult is Failure) { - AndroidOnboardingGraphLog.log( - ActivityNodeFail(activity.nodeId, this.javaClass, contractResult.reason) - ) + AndroidOnboardingGraphLog.log(ActivityNodeFail(activity.nodeId, contractResult.reason)) } val intent = contractResult.intent ?: Intent() @@ -125,18 +124,21 @@ abstract class OnboardingActivityApiContract<I, O> : ActivityResultContract<I, O * * <p>When parsing fails, the failure will be recorded so that it can be fixed. */ - fun validate(activity: Activity, intent: Intent = activity.intent) { + @CanIgnoreReturnValue + fun validate(activity: Activity, intent: Intent = activity.intent): Boolean { try { AndroidOnboardingGraphLog.log( ActivityNodeValidating(activity.nodeId, this.javaClass, intentToIntentData(intent)) ) val unused = extractArgument(intent) + return true } catch (e: Exception) { AndroidOnboardingGraphLog.log( ActivityNodeFailedValidation(activity.nodeId, this.javaClass, e, intentToIntentData(intent)) ) } + return false } private fun extractNodeId(context: Context) = @@ -185,7 +187,7 @@ abstract class OnboardingActivityApiContract<I, O> : ActivityResultContract<I, O AndroidOnboardingGraphLog.log(ActivityNodeResultReceived(id, this.javaClass, result)) if (result is Failure) { - AndroidOnboardingGraphLog.log(ActivityNodeFail(id, this.javaClass, result.reason)) + AndroidOnboardingGraphLog.log(ActivityNodeFail(id, result.reason)) } return result diff --git a/src/com/android/onboarding/nodes/OnboardingGraph.kt b/src/com/android/onboarding/nodes/OnboardingGraph.kt index 1d878d1..094045e 100644 --- a/src/com/android/onboarding/nodes/OnboardingGraph.kt +++ b/src/com/android/onboarding/nodes/OnboardingGraph.kt @@ -138,15 +138,12 @@ class OnboardingGraph(events: Set<OnboardingGraphLog.OnboardingEvent>) { if (e is ActivityNodeResultReceived) { nodeMap.updateNode(e, e.nodeId, e.timestamp, e.nodeName, result = e.result) val node = nodeMap[e.nodeId]!! - node._incomingEdge?.let { - nodeMap.updateNode(id = it.id, timestamp = e.timestamp) - } + node._incomingEdge?.let { nodeMap.updateNode(id = it.id, timestamp = e.timestamp) } } else if (e is ActivityNodeFail) { nodeMap.updateNode( e, e.nodeId, e.timestamp, - e.nodeName, failureReason = IllegalStateException(e.reason) ) val node = nodeMap[e.nodeId]!! diff --git a/src/com/android/onboarding/nodes/OnboardingGraphLog.kt b/src/com/android/onboarding/nodes/OnboardingGraphLog.kt index a39936a..b100923 100644 --- a/src/com/android/onboarding/nodes/OnboardingGraphLog.kt +++ b/src/com/android/onboarding/nodes/OnboardingGraphLog.kt @@ -371,27 +371,17 @@ interface OnboardingGraphLog { * At [timestamp], an [android.app.Activity] with id [nodeId] has failed the [nodeName] contract * because of [reason]. */ - data class ActivityNodeFail - private constructor( + data class ActivityNodeFail( val nodeId: Long, - val nodeName: String, val reason: String?, val timestamp: Instant = Instant.now() ) : OnboardingEvent() { - constructor( - nodeId: Long, - nodeClass: Class<*>, - reason: String?, - timestamp: Instant = Instant.now() - ) : this(nodeId, extractNodeNameFromClass(nodeClass), reason, timestamp) - override fun serialize(): OnboardingProtos.LogProto { return OnboardingProtos.LogProto.newBuilder() .setActivityNodeFail( OnboardingProtos.ActivityNodeFailProto.newBuilder() .setNodeId(nodeId) - .setNodeName(nodeName) .setReason(reason ?: "") .setTimestamp(timestamp.toEpochMilli()) .build() @@ -403,7 +393,6 @@ interface OnboardingGraphLog { fun fromProto(proto: OnboardingProtos.ActivityNodeFailProto) = ActivityNodeFail( nodeId = proto.nodeId, - nodeName = proto.nodeName, reason = proto.reason, timestamp = Instant.ofEpochMilli(proto.timestamp) ) diff --git a/src/com/android/onboarding/nodes/onboarding_nodes.proto b/src/com/android/onboarding/nodes/onboarding_nodes.proto index ee9d25b..9e25eda 100644 --- a/src/com/android/onboarding/nodes/onboarding_nodes.proto +++ b/src/com/android/onboarding/nodes/onboarding_nodes.proto @@ -69,9 +69,8 @@ message ActivityNodeSetResultProto { message ActivityNodeFailProto { optional int64 node_id = 1; - optional string node_name = 2; - optional string reason = 3; - optional int64 timestamp = 4; + optional string reason = 2; + optional int64 timestamp = 3; } message ActivityNodeResultReceivedProto { diff --git a/src/com/android/onboarding/nodes/testing/testapp/AndroidManifest.xml b/src/com/android/onboarding/nodes/testing/testapp/AndroidManifest.xml index 1f45db1..8272cb5 100644 --- a/src/com/android/onboarding/nodes/testing/testapp/AndroidManifest.xml +++ b/src/com/android/onboarding/nodes/testing/testapp/AndroidManifest.xml @@ -8,14 +8,16 @@ android:minSdkVersion="21" android:targetSdkVersion="33"/> + <queries> + <intent> + <!-- used to detect test apps --> + <action android:name="com.android.onboarding.nodes.testing.testapp.red" /> + </intent> + </queries> + <application android:label="Onboarding Graph TestApp" android:theme="@style/Theme.AppCompat.Light" android:taskAffinity=""> - <activity android:name=".MainActivity" android:exported="true"> - <intent-filter> - <action android:name="android.intent.action.MAIN"/> - <category android:name="android.intent.category.LAUNCHER"/> - <category android:name="android.intent.category.DEFAULT"/> - </intent-filter> + <activity android:name=".MainActivity" android:exported="false"> </activity> <activity-alias @@ -25,6 +27,11 @@ <action android:name="com.android.onboarding.nodes.testing.testapp.red"/> <category android:name="android.intent.category.DEFAULT"/> </intent-filter> + <intent-filter> + <action android:name="android.intent.action.MAIN"/> + <category android:name="android.intent.category.LAUNCHER"/> + <category android:name="android.intent.category.DEFAULT"/> + </intent-filter> </activity-alias> <activity-alias diff --git a/src/com/android/onboarding/nodes/testing/testapp/MainActivity.kt b/src/com/android/onboarding/nodes/testing/testapp/MainActivity.kt index 5d25d95..3a12441 100644 --- a/src/com/android/onboarding/nodes/testing/testapp/MainActivity.kt +++ b/src/com/android/onboarding/nodes/testing/testapp/MainActivity.kt @@ -1,17 +1,27 @@ package com.android.onboarding.nodes.testing.testapp -import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.Intent.CATEGORY_DEFAULT +import android.content.Intent.FLAG_ACTIVITY_FORWARD_RESULT +import android.content.pm.PackageManager.ResolveInfoFlags import android.graphics.Color import android.os.Bundle import android.support.v7.app.AppCompatActivity +import android.widget.ArrayAdapter import android.widget.Button +import android.widget.EditText import android.widget.LinearLayout +import android.widget.Spinner +import android.widget.TextView import com.android.onboarding.common.TEST_APP import com.android.onboarding.contracts.ContractResult +import com.android.onboarding.contracts.IdentifyExecutingContract import com.android.onboarding.contracts.OnboardingActivityApiContract import com.android.onboarding.contracts.annotations.OnboardingNode +import com.android.onboarding.contracts.failNode +import com.android.onboarding.contracts.findExecutingContract +import com.android.onboarding.contracts.registerForActivityLaunch class MainActivity : AppCompatActivity() { @@ -20,91 +30,157 @@ class MainActivity : AppCompatActivity() { setContentView(R.layout.activity_main) - val redLauncher = registerForActivityResult(RedContract()) { onResult(it) } + val contracts = arrayOf(RedContract(), BlueContract(), GreenContract()) + val contract = intent?.findExecutingContract(*contracts) ?: RedContract() - val blueLauncher = registerForActivityResult(BlueContract()) { onResult(it) } + if (contract.validate(this, intent)) { + findViewById<TextView>(R.id.status).text = + "Received argument: ${contract.extractArgument(intent).arg}" + } + + val processSpinner = findViewById<Spinner>(R.id.processSpinner) + val packages = + packageManager + .queryIntentActivities( + Intent("com.android.onboarding.nodes.testing.testapp.red"), + ResolveInfoFlags.of(0) + ) + .map { it.activityInfo.packageName } + .toSet() + .toList() + val packageNames = + packages + .map { packageManager.getApplicationLabel(packageManager.getApplicationInfo(it, 0)) } + .toList() + processSpinner.adapter = + ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, packageNames) + processSpinner.setSelection(packages.indexOf(packageName)) + + val contractSpinner = findViewById<Spinner>(R.id.contractSpinner) + contractSpinner.adapter = + ArrayAdapter( + this, + android.R.layout.simple_spinner_dropdown_item, + contracts.map { it.javaClass.getAnnotation(OnboardingNode::class.java)?.name ?: "Unknown" } + ) + contractSpinner.setSelection(contracts.indexOf(contract)) + + val noResultLaunchers = contracts.map { registerForActivityLaunch(it) } + val resultLaunchers = contracts.map { registerForActivityResult(it, this::onResult) } - val greenLauncher = registerForActivityResult(GreenContract()) { onResult(it) } + findViewById<TextView>(R.id.packageName).text = + packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)) - when (intent?.component?.className) { - "com.android.onboarding.nodes.testing.testapp.RedActivity" -> { + findViewById<LinearLayout>(R.id.bg).setBackgroundColor(contract.colour) + + when (contract) { + is RedContract -> { findViewById<LinearLayout>(R.id.bg).setBackgroundColor(Color.RED) - RedContract().validate(this, intent) } - "com.android.onboarding.nodes.testing.testapp.BlueActivity" -> { + is BlueContract -> { findViewById<LinearLayout>(R.id.bg).setBackgroundColor(Color.BLUE) - BlueContract().validate(this, intent) } - "com.android.onboarding.nodes.testing.testapp.GreenActivity" -> { + is GreenContract -> { findViewById<LinearLayout>(R.id.bg).setBackgroundColor(Color.GREEN) - GreenContract().validate(this, intent) } } - findViewById<Button>(R.id.redButton).setOnClickListener { redLauncher.launch(10) } - findViewById<Button>(R.id.blueButton).setOnClickListener { blueLauncher.launch(10) } - findViewById<Button>(R.id.greenButton).setOnClickListener { greenLauncher.launch(10) } - } + findViewById<Button>(R.id.startActivityAndFinishButton).setOnClickListener { + val arg = findViewById<EditText>(R.id.argument).text.toString() + val contractId = findViewById<Spinner>(R.id.contractSpinner).selectedItemId.toInt() + val contract = noResultLaunchers[contractId] + val targetPackageName = packages[processSpinner.selectedItemPosition]!!.toString() - fun onResult(result: Int) {} -} - -@OnboardingNode(component = TEST_APP, name = "Red", hasUi = OnboardingNode.HasUi.YES) -class RedContract : OnboardingActivityApiContract<Int, Int>() { - override fun performCreateIntent(context: Context, arg: Int): Intent = - Intent("com.android.onboarding.nodes.testing.testapp.red").apply { - setComponent( - ComponentName( - context.packageName, - "com.android.onboarding.nodes.testing.testapp.RedActivity" - ) - ) - putExtra("KEY", arg) + contract.launch(ContractArg(arg, targetPackageName)) + finish() } - override fun performExtractArgument(intent: Intent): Int = intent.getIntExtra("KEY", -1) + findViewById<Button>(R.id.startActivityAndForwardButton).setOnClickListener { + val arg = findViewById<EditText>(R.id.argument).text.toString() + val contractId = findViewById<Spinner>(R.id.contractSpinner).selectedItemId.toInt() + val contract = noResultLaunchers[contractId] + val targetPackageName = packages[processSpinner.selectedItemPosition]!!.toString() - override fun performParseResult(result: ContractResult): Int = result.resultCode + contract.launch(ContractArg(arg, targetPackageName, shouldForward = true)) + finish() + } - override fun performSetResult(result: Int): ContractResult = ContractResult.Success(result) -} + findViewById<Button>(R.id.startActivityButton).setOnClickListener { + val arg = findViewById<EditText>(R.id.argument).text.toString() + val contractId = findViewById<Spinner>(R.id.contractSpinner).selectedItemId.toInt() + val contract = noResultLaunchers[contractId] + val targetPackageName = packages[processSpinner.selectedItemPosition]!!.toString() -@OnboardingNode(component = TEST_APP, name = "Blue", hasUi = OnboardingNode.HasUi.YES) -class BlueContract : OnboardingActivityApiContract<Int, Int>() { - override fun performCreateIntent(context: Context, arg: Int): Intent = - Intent("com.android.onboarding.nodes.testing.testapp.blue").apply { - setComponent( - ComponentName( - context.packageName, - "com.android.onboarding.nodes.testing.testapp.BlueActivity" - ) - ) - putExtra("KEY", arg) + contract.launch(ContractArg(arg, targetPackageName)) } - override fun performExtractArgument(intent: Intent): Int = intent.getIntExtra("KEY", -1) + findViewById<Button>(R.id.startActivityForResultButton).setOnClickListener { + val arg = findViewById<EditText>(R.id.argument).text.toString() + val contractId = findViewById<Spinner>(R.id.contractSpinner).selectedItemId.toInt() + val contract = resultLaunchers[contractId] + val targetPackageName = packages[processSpinner.selectedItemPosition]!!.toString() - override fun performParseResult(result: ContractResult): Int = result.resultCode + contract.launch(ContractArg(arg, targetPackageName)) + } - override fun performSetResult(result: Int): ContractResult = ContractResult.Success(result) + findViewById<Button>(R.id.finishButton).setOnClickListener { finish() } + findViewById<Button>(R.id.crashButton).setOnClickListener { null!! } + findViewById<Button>(R.id.failNodeButton).setOnClickListener { + failNode(findViewById<EditText>(R.id.argument).text.toString()) + } + findViewById<Button>(R.id.setResultAndFinishButton).setOnClickListener { + contract.setResult(this, findViewById<EditText>(R.id.argument).text.toString()) + finish() + } + } + + fun onResult(result: String) { + findViewById<TextView>(R.id.status).text = "Received result: $result" + } } -@OnboardingNode(component = TEST_APP, name = "Green", hasUi = OnboardingNode.HasUi.YES) -class GreenContract : OnboardingActivityApiContract<Int, Int>() { - override fun performCreateIntent(context: Context, arg: Int): Intent = - Intent("com.android.onboarding.nodes.testing.testapp.green").apply { - setComponent( - ComponentName( - context.packageName, - "com.android.onboarding.nodes.testing.testapp.GreenActivity" - ) - ) - putExtra("KEY", arg) +data class ContractArg( + val arg: String, + val targetPackageName: String, + val shouldForward: Boolean = false +) + +abstract class ColourContract(val colour: Int, private val name: String) : + OnboardingActivityApiContract<ContractArg, String>(), IdentifyExecutingContract { + override fun isExecuting(intent: Intent) = intent.action?.endsWith(".$name") ?: false + + override fun performCreateIntent(context: Context, arg: ContractArg): Intent = + Intent("com.android.onboarding.nodes.testing.testapp.$name").apply { + setPackage(arg.targetPackageName) + putExtra("KEY", arg.arg) + addCategory(CATEGORY_DEFAULT) + + if (arg.shouldForward) { + addFlags(FLAG_ACTIVITY_FORWARD_RESULT) + } } - override fun performExtractArgument(intent: Intent): Int = intent.getIntExtra("KEY", -1) + override fun performExtractArgument(intent: Intent): ContractArg = + ContractArg( + intent.getStringExtra("KEY")!!, + intent.`package` ?: "", + intent.hasFlag(FLAG_ACTIVITY_FORWARD_RESULT) + ) - override fun performParseResult(result: ContractResult): Int = result.resultCode + override fun performParseResult(result: ContractResult): String = + result.intent?.getStringExtra("KEY")!! - override fun performSetResult(result: Int): ContractResult = ContractResult.Success(result) + override fun performSetResult(result: String): ContractResult = + ContractResult.Success(1, Intent().apply { putExtra("KEY", result) }) } + +@OnboardingNode(component = TEST_APP, name = "Red", hasUi = OnboardingNode.HasUi.YES) +class RedContract : ColourContract(Color.RED, "red") + +@OnboardingNode(component = TEST_APP, name = "Blue", hasUi = OnboardingNode.HasUi.YES) +class BlueContract : ColourContract(Color.BLUE, "blue") + +@OnboardingNode(component = TEST_APP, name = "Green", hasUi = OnboardingNode.HasUi.YES) +class GreenContract : ColourContract(Color.GREEN, "green") + +fun Intent.hasFlag(flag: Int) = (this.flags and flag) == flag diff --git a/src/com/android/onboarding/nodes/testing/testapp/res/layout/activity_main.xml b/src/com/android/onboarding/nodes/testing/testapp/res/layout/activity_main.xml index 5127e92..2473c36 100644 --- a/src/com/android/onboarding/nodes/testing/testapp/res/layout/activity_main.xml +++ b/src/com/android/onboarding/nodes/testing/testapp/res/layout/activity_main.xml @@ -4,67 +4,71 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> - - <LinearLayout + <TextView + android:id="@+id/packageName" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="" /> + <TextView + android:id="@+id/status" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="" /> + <EditText + android:id="@+id/argument" android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="horizontal"> -<!-- <LinearLayout--> -<!-- android:layout_width="match_parent"--> -<!-- android:layout_height="match_parent"--> -<!-- android:orientation="vertical">--> -<!-- <TextView--> -<!-- android:id="@+id/app1Label"--> -<!-- android:layout_width="match_parent"--> -<!-- android:layout_height="wrap_content"--> -<!-- android:text="App 1" />--> -<!-- <Button--> -<!-- android:id="@+id/redButton2"--> -<!-- android:layout_width="match_parent"--> -<!-- android:layout_height="wrap_content"--> -<!-- android:text="Launch Red" />--> -<!-- <Button--> -<!-- android:id="@+id/blueButton2"--> -<!-- android:layout_width="match_parent"--> -<!-- android:layout_height="wrap_content"--> -<!-- android:text="Launch Blue" />--> -<!-- <Button--> -<!-- android:id="@+id/greenButton2"--> -<!-- android:layout_width="match_parent"--> -<!-- android:layout_height="wrap_content"--> -<!-- android:text="Launch Green" />--> -<!-- </LinearLayout>--> - <LinearLayout - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical"> -<!-- <TextView--> -<!-- android:id="@+id/app2Label"--> -<!-- android:layout_width="match_parent"--> -<!-- android:layout_height="wrap_content"--> -<!-- android:text="App 2" />--> + android:layout_height="wrap_content" + android:ems="10" + android:inputType="text" + android:hint="Argument/Result" /> + <Spinner + android:id="@+id/processSpinner" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> + <Spinner + android:id="@+id/contractSpinner" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> + <Button - android:id="@+id/redButton" + android:id="@+id/startActivityButton" android:layout_width="fill_parent" android:layout_height="wrap_content" - android:text="Launch Red" /> + android:text="Start Activity" /> + <Button + android:id="@+id/startActivityAndForwardButton" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:text="Start Activity and Forward" /> <Button - android:id="@+id/blueButton" + android:id="@+id/startActivityForResultButton" android:layout_width="fill_parent" android:layout_height="wrap_content" - android:text="Launch Blue" /> + android:text="Start Activity For Result" /> <Button - android:id="@+id/greenButton" + android:id="@+id/startActivityAndFinishButton" android:layout_width="fill_parent" android:layout_height="wrap_content" - android:text="Launch Green" /> - </LinearLayout> - </LinearLayout> + android:text="Start Activity And Finish" /> <Button android:id="@+id/failNodeButton" - android:layout_width="wrap_content" + android:layout_width="fill_parent" android:layout_height="wrap_content" - android:layout_weight="1" android:text="Fail Node" /> + <Button + android:id="@+id/crashButton" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:text="Crash" /> + <Button + android:id="@+id/finishButton" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:text="Finish" /> + <Button + android:id="@+id/setResultAndFinishButton" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:text="Set Result and Finish" /> </LinearLayout> |