Skip to content

Commit

Permalink
add readme
Browse files Browse the repository at this point in the history
  • Loading branch information
xuduo committed Dec 7, 2023
1 parent 72422ec commit dd8c433
Show file tree
Hide file tree
Showing 11 changed files with 196 additions and 74 deletions.
58 changes: 55 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,59 @@
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=xuduo_Android-Test-Recorder&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=xuduo_Android-Test-Recorder)
[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=xuduo_Android-Test-Recorder&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=xuduo_Android-Test-Recorder)

* Working in progress
* Like Android Espresso Test Recorder, but it runs on the phone, and works with apps built with
compose and flutter.
## Introduction
The Android Test Recorder is a powerful tool for automating UI tests, similar to [Google's Espresso Test Recorder](https://developer.android.com/studio/test/other-testing-tools/espresso-test-recorder). It simplifies the process of creating test scripts for Android applications.

## Compatibility and Features
- Runs in the Android OS, don't need adb connection
- Compatible with Android 8 to 14.
- Supports apps built with Flutter and Compose, which doesn't have a Android view hierarchy.
- No rooting required.

## Usage Example
1. **Capture User Interactions**: Utilizes AccessibilityService, MediaProjection, and OverlayView to capture user clicks.
![Capture](readmes/capture.png)
2. **Automatic Script Generation**: Automatically generates test scripts based on captured user interactions.
![CodeGen](readmes/gencode.png)
3. **Helper Code for Test Classes**: Incorporates helper code in your base test class, with an example provided for Kotlin & UIAutomator.

### Helper Code Example

```kotlin
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import androidx.test.platform.app.InstrumentationRegistry

open class BaseTest {

private val uiDevice: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())

fun clickText(text: String) {
val selector = By.text(text)
val obj = uiDevice.findObject(selector)
obj?.let {
if (!it.wait(Until.clickable(true), 5000)) {
throw AssertionError("View with text '$text' not found or not clickable")
}
it.click()
} ?: throw AssertionError("View with text '$text' not found")
}

fun clickContentDescription(description: String) {
val selector = By.desc(description)
val obj = uiDevice.findObject(selector)
obj?.let {
if (!it.wait(Until.clickable(true), 5000)) {
throw AssertionError("View with content description '$description' not found or not clickable")
}
it.click()
} ?: throw AssertionError("View with content description '$description' not found")
}
}
```
## Project Structure
1. apps: Contains the main application of the recorder.
2. model: Includes ViewModels and model-level code for the main application.
3. common: Features quality of life code that enhances the overall functionality.
4. dummy-app-for-testing: A simple application built using Jetpack Compose, serving as a test subject for the main app.
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.xd.testrecorder.recording

import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.BitmapFactory
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
Expand Down Expand Up @@ -36,6 +40,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
Expand Down Expand Up @@ -123,9 +128,22 @@ private fun ActionImageScreenContent(
.clickable {} // use the passed lambda here
.padding(16.dp)
) {
val code = ActionCodeConverter.getConverter(CodeConverterOptions())
.toCode(action)
val context = LocalContext.current
Text(
text = ActionCodeConverter.getConverter(CodeConverterOptions())
.toCode(action)
text = code,
Modifier.clickable{
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(
"code",
code
)
clipboard.setPrimaryClip(clip)
Toast
.makeText(context, "Code copied", Toast.LENGTH_SHORT)
.show()
}
)
}
}
Expand All @@ -143,8 +161,7 @@ fun ImageBox(actionImage: ActionImage, action: Action, modifier: Modifier) {
actionImage.screenShot,
0,
actionImage.screenShot.size
)
.asImageBitmap()
).asImageBitmap()
BoxWithConstraints(
modifier
.fillMaxSize()
Expand Down Expand Up @@ -210,15 +227,15 @@ fun ImageBox(actionImage: ActionImage, action: Action, modifier: Modifier) {
x = clickRect.left.toFloat(),
y = clickRect.top.toFloat()
),
style = Stroke(width = 1.dp.toPx())
style = Stroke(width = 2.dp.toPx())
)
if (action.featureViewBounds != action.clickableViewBounds) {
val featureRect = action.getRelativeViewBounds(
action.featureViewBounds,
size
)
drawRect(
color = Color.Yellow,
color = Color.Blue,
size = Size(
width = featureRect.width().toFloat(), height = featureRect.height()
.toFloat()
Expand All @@ -227,7 +244,7 @@ fun ImageBox(actionImage: ActionImage, action: Action, modifier: Modifier) {
x = featureRect.left.toFloat(),
y = featureRect.top.toFloat()
),
style = Stroke(width = 1.dp.toPx())
style = Stroke(width = 2.dp.toPx())
)
}
}
Expand Down
97 changes: 49 additions & 48 deletions app/src/main/java/com/xd/testrecorder/recording/ActionListScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.xd.testrecorder.recording
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
Expand All @@ -25,13 +26,16 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import com.xd.common.coroutine.let3
import com.xd.common.nav.LocalLogger
import com.xd.common.nav.LocalNavController
import com.xd.common.widget.AppBar
import com.xd.common.widget.DataLoadingContent
import com.xd.common.widget.LoadingContent
import com.xd.testrecorder.R
import com.xd.testrecorder.codegen.CodeGeneratorViewModel
import com.xd.testrecorder.data.CodeConverterOptions
import com.xd.testrecorder.data.Recording
import com.xd.testrecorder.goToActionImage
import io.github.kbiakov.codeview.CodeView
import io.github.kbiakov.codeview.OnCodeLineClickListener
import io.github.kbiakov.codeview.adapters.Format
Expand All @@ -56,20 +60,6 @@ fun ActionListScreen(
modifier = Modifier.padding(it)
)
}
@Composable
fun ExampleComposable(nullableValue: String?) {
nullableValue?.let { nonNullValue ->
// This is a let block
MyTextComposable(text = nonNullValue) // Calling a Composable function inside the let block
}
}


}

@Composable
fun MyTextComposable(text: String) {
Text(text = text)
}

@Composable
Expand All @@ -87,55 +77,66 @@ private fun ActionListScreenContent(
val logger = LocalLogger.current
LocalLogger.current.d("RecordingViewModel.getActionsByRecordingId() $dataL")
val context = LocalContext.current
com.xd.common.coroutine.let3(dataL, recordingL, optionsL) { data, recording, options ->

let3(dataL, recordingL, optionsL) { actions, recording, options ->
val copy = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val code = codeGenModel.generateCode(recording, actions).code
val clip = ClipData.newPlainText(
"label",
codeGenModel.generateCode(recording, data).toString()
"code",
code
)
clipboard.setPrimaryClip(clip)
Toast
.makeText(context, "${code.lines().size} lines of code copied", Toast.LENGTH_SHORT)
.show()
}
Column {
SingleChoiceView(
title = "Language",
title = "Language:",
options = listOf("Java", "Kotlin"),
options,
copy = copy
) { selectedOption ->
// Handle the selected option
options.copy(lang = selectedOption).let { codeGenModel.updateOptions(it) }
}
val converter = codeGenModel.getConverter()
DataLoadingContent(
data
) {
val code = codeGenModel.generateCode(Recording(), actions = it)
AndroidView(factory = { ctx ->
// Create an Android View here. For example, a TextView.
CodeView(ctx).apply {
this.setOptions(
Options.get(ctx)
.withLanguage("kotlin")
.withFormat(
Format(scaleFactor = 1.5f, fontSize = 18.dp.value)
)
.addCodeLineClickListener(object : OnCodeLineClickListener {
override fun onCodeLineClicked(n: Int, line: String) {
// Implement your logic here
logger.i("code clicked ${n - code.funLines},$line")

val code = codeGenModel.generateCode(Recording(), actions = actions)
val nav = LocalNavController.current
AndroidView(factory = { ctx ->
// Create an Android View here. For example, a TextView.
CodeView(ctx).apply {
this.setOptions(
Options.get(ctx)
.withLanguage("kotlin")
.withFormat(
Format(scaleFactor = 1.5f, fontSize = 18.dp.value)
)
.addCodeLineClickListener(object : OnCodeLineClickListener {
override fun onCodeLineClicked(n: Int, line: String) {
// Implement your logic here
val index = n - code.funLines
logger.i("code clicked ${index},$line")
if (index in actions.indices) {
nav.goToActionImage(
recordingId = recordingId,
actions[index].id
)
}
})
.withCode(code.code)
.withTheme(ColorTheme.SOLARIZED_LIGHT)
)
}
},
update = { view ->
view.setCode(code = code.code)
})
}
}
})
.withCode(code.code)
.withTheme(ColorTheme.SOLARIZED_LIGHT)
)
}
},
update = { view ->
view.setCode(code = code.code)
})
}
} ?: run {
LoadingContent()
}
}

Expand All @@ -148,7 +149,7 @@ fun SingleChoiceView(
onOptionSelected: (String) -> Unit
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = title, Modifier.padding(horizontal = 4.dp))
Text(text = title, Modifier.padding(start = 8.dp))
Row {
options.forEach { option ->
Row(
Expand Down
41 changes: 38 additions & 3 deletions common/src/main/java/com/xd/common/widget/ComposeUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ package com.xd.common.widget

import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
Expand All @@ -20,8 +27,12 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
Expand Down Expand Up @@ -150,7 +161,7 @@ private fun EmptyContent(
}

@Composable
private fun LoadingContent(
fun LoadingContent(
modifier: Modifier = Modifier,
@DrawableRes noTasksIconRes: Int = R.drawable.loading
) {
Expand All @@ -159,9 +170,9 @@ private fun LoadingContent(
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
AnimatedImage(
painter = painterResource(id = noTasksIconRes),
contentDescription = stringResource(R.string.loading),
modifier = Modifier.size(96.dp)
Expand Down Expand Up @@ -191,4 +202,28 @@ private fun ErrorContent(
)
Text(message)
}
}


@Composable
fun AnimatedImage(
modifier: Modifier = Modifier,
painter: Painter,
contentDescription: String,
) {
val infiniteTransition = rememberInfiniteTransition(label = "rotate")
val animatedDegrees by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 2000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
), label = "rotate"
)

Image(
painter = painter,
contentDescription = contentDescription,
modifier = modifier.rotate(animatedDegrees)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class TouchAccessibilityService : AccessibilityService() {
logger.d("onDestroy")
service = null
recordingPackageName = "not.recording"
OverlayService.service?.adjustPassThrough()
}

override fun onAccessibilityEvent(event: AccessibilityEvent?) {
Expand Down Expand Up @@ -160,7 +161,12 @@ class TouchAccessibilityService : AccessibilityService() {
// Compare the bounds to find the smallest node
val foundNodeBounds = Rect()
foundNode.getBoundsInScreen(foundNodeBounds)
if (bestMatch == null || foundNodeBounds.width() * foundNodeBounds.height() < nodeBounds.width() * nodeBounds.height()) {
// find the smallest or the shortest text
if (bestMatch == null || foundNodeBounds.width() * foundNodeBounds.height() < nodeBounds.width() * nodeBounds.height() ||
(foundNode.contentDescription?.length
?: 0) < (bestMatch?.contentDescription?.length ?: 0) ||
(foundNode.text?.length ?: 0) < (bestMatch?.text?.length ?: 0)
) {
bestMatch = foundNode
}
}
Expand Down
Loading

0 comments on commit dd8c433

Please sign in to comment.