Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue 699 compose doc loc metadata #700

Merged
merged 3 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.kaspersky.kaspresso.docloc

import com.kaspersky.kaspresso.device.screenshots.screenshotmaker.ScreenshotMaker
import com.kaspersky.kaspresso.docloc.metadata.saver.MetadataSaver
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only import without usage?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is used in the consctructor. Before my changes MetadataSaver was in the same pachage as DocLocScreenshotCapturer. I made renamed it to the DefautMetadataSaver and extracted the interface MetadataSaver. Now it has to be imported

import com.kaspersky.kaspresso.files.resources.ResourceFilesProvider
import com.kaspersky.kaspresso.internal.wait.wait
import com.kaspersky.kaspresso.logger.UiTestLogger
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.kaspersky.kaspresso.device.activities.metadata
package com.kaspersky.kaspresso.docloc.metadata

internal data class Metadata(val window: Window)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
package com.kaspersky.kaspresso.device.activities.metadata
package com.kaspersky.kaspresso.docloc.metadata.extractor

import android.app.Activity
import android.content.res.Resources
import android.view.View
import android.widget.TextView
import androidx.test.espresso.util.TreeIterables
import com.google.android.material.appbar.CollapsingToolbarLayout
import com.kaspersky.kaspresso.device.activities.Activities
import com.kaspersky.kaspresso.docloc.metadata.LocalizedString
import com.kaspersky.kaspresso.docloc.metadata.Metadata
import com.kaspersky.kaspresso.docloc.metadata.Window
import com.kaspersky.kaspresso.logger.UiTestLogger

/**
* The utility class to collect metadata from a window.
*/
internal class ActivityMetadata(
private val logger: UiTestLogger
) {
internal class ActivityMetadataExtractor(
private val logger: UiTestLogger,
private val activities: Activities,
) : MetadataExtractor {

companion object {
private const val INDEX_SEPARATOR = '_'
private val metadataExtractorHelper = MetadataExtractorHelper()

override fun getMetadata(): Metadata {
val activity = activities.getResumed() ?: throw RuntimeException("Failed to get current activity")
return getFromActivity(activity)
}

/**
Expand All @@ -27,16 +35,15 @@ internal class ActivityMetadata(
* @param activity activity to collect metadata from.
* @return Metadata for the activity.
*/
internal fun getFromActivity(activity: Activity): Metadata {
return getMetadata(activity)
private fun getFromActivity(activity: Activity): Metadata {
return createMetadata(activity)
}

private fun getMetadata(activity: Activity): Metadata {
private fun createMetadata(activity: Activity): Metadata {
with(activity.window.decorView) {
val localizedStrings =
resolveAmbiguous(
getLocalizedStrings(this)
)
val localizedStrings = metadataExtractorHelper.resolveAmbiguous(
getLocalizedStrings(this)
)
val window = Window(
left,
top,
Expand Down Expand Up @@ -91,20 +98,4 @@ internal class ActivityMetadata(
"[id:${Integer.toHexString(layout.id)}]"
}
}

private fun resolveAmbiguous(localizedStrings: List<LocalizedString>): List<LocalizedString> {
return localizedStrings.groupBy { it.locValueDescription }
.values
.flatMap { groupedById ->
if (groupedById.size == 1) groupedById else addIndexes(
groupedById
)
}
}

private fun addIndexes(groupedById: List<LocalizedString>): List<LocalizedString> {
return groupedById.mapIndexed { index, locString ->
locString.copy(locValueDescription = "${locString.locValueDescription}$INDEX_SEPARATOR${index + 1}")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.kaspersky.kaspresso.docloc.metadata.extractor

import com.kaspersky.kaspresso.docloc.metadata.Metadata

internal interface MetadataExtractor {
fun getMetadata(): Metadata
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.kaspersky.kaspresso.docloc.metadata.extractor

import com.kaspersky.kaspresso.docloc.metadata.LocalizedString

internal class MetadataExtractorHelper {
fun resolveAmbiguous(localizedStrings: List<LocalizedString>): List<LocalizedString> {
return localizedStrings.groupBy { it.locValueDescription }
.values
.flatMap { groupedById ->
if (groupedById.size == 1) groupedById else addIndexes(
groupedById
)
}
}

private fun addIndexes(groupedById: List<LocalizedString>): List<LocalizedString> {
return groupedById.mapIndexed { index, locString ->
locString.copy(locValueDescription = "${locString.locValueDescription}$INDEX_SEPARATOR${index + 1}")
}
}

companion object {
private const val INDEX_SEPARATOR = '_'
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.kaspersky.kaspresso.docloc.metadata.extractor

import android.app.Activity
import androidx.test.uiautomator.By
import androidx.test.uiautomator.StaleObjectException
import androidx.test.uiautomator.UiDevice
import com.kaspersky.kaspresso.device.activities.Activities
import com.kaspersky.kaspresso.docloc.metadata.LocalizedString
import com.kaspersky.kaspresso.docloc.metadata.Metadata
import com.kaspersky.kaspresso.docloc.metadata.Window
import com.kaspersky.kaspresso.logger.UiTestLogger

internal class UiMetadataExtractor(
private val uiDevice: UiDevice,
private val activities: Activities,
private val logger: UiTestLogger,
) : MetadataExtractor {

private val metadataExtractorHelper = MetadataExtractorHelper()

override fun getMetadata(): Metadata {
val activity = activities.getResumed() ?: throw RuntimeException("Failed to get current activity")
return createMetadata(activity)
}

private fun createMetadata(activity: Activity): Metadata {
val localizedStrings = metadataExtractorHelper.resolveAmbiguous(
getLocalizedStrings()
)
val window = activity.window.decorView.run {
Window(
left,
top,
width,
height,
localizedStrings
)
}
return Metadata(window)
}

private fun getLocalizedStrings(): List<LocalizedString> {
uiDevice.setCompressedLayoutHierarchy(false)
val objectsWithText = uiDevice.findObjects(By.enabled(true))
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100% needs better UiObjects tree parsing. Probably would be better if we used recursion

.filterNotNull()
.filter {
try {
(it.resourceName != null && it.children.any { !it.text.isNullOrBlank() }) ||
(!it.text.isNullOrBlank() && it.resourceName != null)
} catch (ex: StaleObjectException) {
logger.e("UiMetadataExtractor::getLocalizedStrings() - Error while loading object")
false
}
}

val localizedStrings = mutableListOf<LocalizedString>()
/*
There might be a case when the text itself won't have an ID, but rather the text container will.
For example if we set an id to the button, Ui automator will see it as a container with an id and a child - text view without an id

Probably buggy. Need better solution.
*/
for (obj in objectsWithText) {
try {
val resName = obj.resourceName ?: continue
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe it will be better to throw exception? Or we really need to skip item without res name?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is to pick the view that might be the text container or a text itself. Either way we need an id which might be nullable, but I don't think we have to throw an exception

if (obj.text.isNullOrBlank()) { // the text container
obj.children
.filter { !it.text.isNullOrBlank() }
.forEach {
val coords = it.visibleBounds
localizedStrings.add(
LocalizedString(
text = it.text,
locValueDescription = resName,
left = coords.left,
top = coords.top,
width = coords.width(),
height = coords.height(),
)
)
}
} else { // the text itself
val coords = obj.visibleBounds
localizedStrings.add(
LocalizedString(
text = obj.text,
locValueDescription = resName,
left = coords.left,
top = coords.top,
width = coords.width(),
height = coords.height(),
)
)
}
} catch (ex: StaleObjectException) {
logger.e("UiMetadataExtractor::getLocalizedStrings() - error while processing found objects")
}
}

return localizedStrings
}
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,28 @@
package com.kaspersky.kaspresso.docloc
package com.kaspersky.kaspresso.docloc.metadata.saver

import com.kaspersky.kaspresso.device.activities.Activities
import com.kaspersky.kaspresso.device.activities.metadata.ActivityMetadata
import com.kaspersky.kaspresso.device.apps.Apps
import com.kaspersky.kaspresso.docloc.metadata.extractor.MetadataExtractor
import com.kaspersky.kaspresso.internal.extensions.other.safeWrite
import com.kaspersky.kaspresso.internal.extensions.other.toXml
import com.kaspersky.kaspresso.logger.UiTestLogger
import java.io.File

internal class MetadataSaver(
internal class DefaultMetadataSaver(
private val activities: Activities,
private val apps: Apps,
private val logger: UiTestLogger
) {
private val activityMetadata = ActivityMetadata(logger)
private val logger: UiTestLogger,
private val metadataExtractor: MetadataExtractor,
) : MetadataSaver {

fun saveScreenshotMetadata(folderPath: File, name: String) {
override fun saveScreenshotMetadata(folderPath: File, name: String) {
val activity = activities.getResumed()
if (activity == null) {
logger.e("Activity is null when saving metadata $name")
return
}
runCatching {
val metadata = activityMetadata.getFromActivity(activity)
.toXml(apps.targetAppPackageName)
val metadata = metadataExtractor.getMetadata().toXml(apps.targetAppPackageName)
folderPath.resolve("$name.xml").safeWrite(logger, metadata)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.kaspersky.kaspresso.docloc.metadata.saver

import java.io.File

interface MetadataSaver {
fun saveScreenshotMetadata(folderPath: File, name: String)
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package com.kaspersky.kaspresso.internal.extensions.other

import android.text.TextUtils.htmlEncode
import com.kaspersky.kaspresso.device.activities.metadata.LocalizedString
import com.kaspersky.kaspresso.device.activities.metadata.Metadata
import com.kaspersky.kaspresso.docloc.metadata.LocalizedString
import com.kaspersky.kaspresso.docloc.metadata.Metadata

/**
* Transforms [Metadata] object to an xml string.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,21 @@ package com.kaspersky.kaspresso.params

/**
* @param quality quality of the PNG compression; range: 0-100
* @param metadataExtractor determines the API used to get metadata from the app
*/
class ScreenshotParams(
val quality: Int = 100
val quality: Int = 100,
val metadataExtractor: MetadataExtractors = MetadataExtractors.Default,
)

enum class MetadataExtractors {
/**
* Traverses XML's views hierarchy to get views metadata
*/
Default,

/**
* Dumps and traverses UI automator tree to get views metadata. Recommended to use for Compose screens
*/
UiAutomator,
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import com.kaspersky.kaspresso.device.screenshots.screenshotmaker.DocLocScreensh
import com.kaspersky.kaspresso.device.screenshots.screenshotmaker.ExternalScreenshotMaker
import com.kaspersky.kaspresso.device.screenshots.screenshotmaker.InternalScreenshotMaker
import com.kaspersky.kaspresso.docloc.DocLocScreenshotCapturer
import com.kaspersky.kaspresso.docloc.MetadataSaver
import com.kaspersky.kaspresso.docloc.metadata.extractor.ActivityMetadataExtractor
import com.kaspersky.kaspresso.docloc.metadata.extractor.UiMetadataExtractor
import com.kaspersky.kaspresso.docloc.metadata.saver.DefaultMetadataSaver
import com.kaspersky.kaspresso.docloc.rule.LocaleRule
import com.kaspersky.kaspresso.docloc.rule.ToggleNightModeRule
import com.kaspersky.kaspresso.files.dirs.DefaultDirsProvider
Expand All @@ -33,6 +35,7 @@ import com.kaspersky.kaspresso.internal.extensions.other.getAllInterfaces
import com.kaspersky.kaspresso.internal.invocation.UiInvocationHandler
import com.kaspersky.kaspresso.kaspresso.Kaspresso
import com.kaspersky.kaspresso.logger.UiTestLogger
import com.kaspersky.kaspresso.params.MetadataExtractors
import com.kaspersky.kaspresso.params.ScreenshotParams
import org.junit.Before
import org.junit.Rule
Expand Down Expand Up @@ -180,7 +183,15 @@ abstract class DocLocScreenshotTestCase(
),
fullWindowScreenshotMaker = InternalScreenshotMaker(kaspresso.device.activities, screenshotParams)
),
metadataSaver = MetadataSaver(kaspresso.device.activities, kaspresso.device.apps, logger)
metadataSaver = DefaultMetadataSaver(
kaspresso.device.activities,
kaspresso.device.apps,
logger,
metadataExtractor = when (screenshotParams.metadataExtractor) {
MetadataExtractors.Default -> ActivityMetadataExtractor(logger, kaspresso.device.activities)
MetadataExtractors.UiAutomator -> UiMetadataExtractor(kaspresso.device.uiDevice, kaspresso.device.activities, logger)
}
)
)
}

Expand Down
Loading