Skip to content
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions tools/dwf-validator-cli/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
33 changes: 33 additions & 0 deletions tools/dwf-validator-cli/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
plugins {
id("org.jetbrains.kotlin.jvm")
id("application")
}

repositories {
mavenCentral()
}

dependencies {
implementation 'commons-cli:commons-cli:1.5.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
implementation project(':dwf-validator')
testImplementation 'junit:junit:4.13.2'
}

application {
mainClass = 'com.google.wear.watchface.validator.cli.AppKt'
}

jar {
duplicatesStrategy(DuplicatesStrategy.EXCLUDE)

from {
configurations.runtimeClasspath.collect {
it.isDirectory() ? it : zipTree(it)
}
}

manifest {
attributes 'Main-Class': application.mainClass
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.google.wear.watchface.validator.cli

import com.google.wear.watchface.validator.JvmWatchFaceDocument
import com.google.wear.watchface.validator.WatchFaceValidator
import com.google.wear.watchface.validator.XmlReader
import com.google.wear.watchface.validator.error.ValidationResult
import com.google.wear.watchface.validator.specification.WFF_SPECIFICATION
import java.nio.file.NoSuchFileException
import java.util.zip.ZipFile
import kotlin.system.exitProcess

internal const val FAILURE = 1
internal const val SUCCESS = 0

/**
* Command line application which validates .xml files in the res/raw/ directory of a specified .apk
* file.
*
* The .apk file should be passed as the first argument to the application. If the raw watch face
* xml file fails validation then the application will exit with exit code 1.
*/
fun main(args: Array<String>) {
Settings.parseFromArguments(args)?.let { App.run(it) }
}

object App {
/**
* Runs the validator on a specified .apk file. The validator is invoked on the xml file in
* res/raw/ and the results are printed to stderr.
*
* @param settings The settings parsed from the command line arguments.
*/
fun run(settings: Settings) {
val validationResult =
if (settings.rawXml) {
validateRawXml(settings.sourcePath)
} else {
validateApk(settings.sourcePath)
}

exitProcess(if (validationResult is ValidationResult.Failure) FAILURE else SUCCESS)
}

fun validateApk(apkPath: String): ValidationResult {
val zipFile = ZipFile(apkPath)
val entries = zipFile.entries()

while (entries.hasMoreElements()) {
val entry = entries.nextElement()
if (entry.name.startsWith("res/raw/") && entry.name.endsWith(".xml")) {
val document =
JvmWatchFaceDocument.of(
XmlReader.readFromInputStream(zipFile.getInputStream(entry))
)
val validationResult =
WatchFaceValidator(WFF_SPECIFICATION).getValidationResult(document)

printValidationReport(zipFile.name, validationResult)
return validationResult
}
}

throw NoSuchFileException("No XML file found in res/raw/ in ${zipFile.name}")
}

fun validateRawXml(xmlPath: String): ValidationResult {
val document = JvmWatchFaceDocument.of(XmlReader.fromFilePath(xmlPath))
val validator = WatchFaceValidator(WFF_SPECIFICATION)
val validationResult = validator.getValidationResult(document)

printValidationReport(xmlPath, validationResult)
return validationResult
}

private fun printValidationReport(fileName: String, validationResult: ValidationResult) {
val messageBuilder = StringBuilder()

when (validationResult) {
is ValidationResult.Success ->
messageBuilder.appendLine("Validation Succeeded for file: $fileName.")

is ValidationResult.PartialSuccess ->
messageBuilder
.appendLine(
"Validation Succeeded for file: $fileName with some invalid versions."
)
.appendLine(
"Valid for versions: ${validationResult.validVersions.joinToString(", ")}.\n"
)
.appendLine(ValidationFailureMessage(validationResult.errorMap))

is ValidationResult.Failure ->
messageBuilder
.appendLine("Validation Failed for file: $fileName.\n")
.appendLine(ValidationFailureMessage(validationResult.errorMap))
}

System.err.println(messageBuilder.toString())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.google.wear.watchface.validator.cli

import org.apache.commons.cli.DefaultParser
import org.apache.commons.cli.HelpFormatter
import org.apache.commons.cli.Option
import org.apache.commons.cli.Options
import org.apache.commons.cli.ParseException

class Settings(val sourcePath: String, val rawXml: Boolean = false) {
companion object {
val cliInvokeCommand = "java -jar dwf-validator-cli.jar"

fun parseFromArguments(arguments: Array<String>): Settings? {
val sourcePathOption =
Option.builder()
.longOpt("source")
.desc("Path to the watch face package to be validated.")
.hasArg()
.required()
.build()

val rawXmlOption =
Option.builder("x")
.longOpt("raw-xml")
.desc("Flag to indicate the source is a raw xml file rather than an apk.")
.build()

val options = Options()
options.addOption(sourcePathOption)
options.addOption(rawXmlOption)

val parser = DefaultParser()
try {
val line = parser.parse(options, arguments)
return Settings(line.getOptionValue(sourcePathOption), line.hasOption(rawXmlOption))
} catch (e: ParseException) {
println("Error: " + e.localizedMessage)
HelpFormatter().printHelp(cliInvokeCommand, options, true)
return null
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.google.wear.watchface.validator.cli

import com.google.wear.watchface.validator.error.AttributeValueError
import com.google.wear.watchface.validator.error.ContentError
import com.google.wear.watchface.validator.error.ErrorMap
import com.google.wear.watchface.validator.error.ExpressionSyntaxError
import com.google.wear.watchface.validator.error.ExpressionVersionEliminationError
import com.google.wear.watchface.validator.error.IllegalAttributeError
import com.google.wear.watchface.validator.error.IllegalTagError
import com.google.wear.watchface.validator.error.RequiredConditionFailedError
import com.google.wear.watchface.validator.error.TagOccurrenceError
import com.google.wear.watchface.validator.error.UnknownError
import com.google.wear.watchface.validator.error.ValidationError
import com.google.wear.watchface.validator.error.VersionEliminationError

internal const val GLOBAL_ERROR_KEY = 0
internal const val BULLET = " - "
internal const val INDENT = " "

/**
* Class for formatting validation failure messages.
*
* @param errorMap The map of errors to format.
*/
class ValidationFailureMessage(private val errorMap: ErrorMap) {
private val stringBuilder = StringBuilder()

override fun toString(): String {
errorMap.forEach { (key, errors) ->
stringBuilder.appendLine(
if (key == GLOBAL_ERROR_KEY) "Global Errors:\n" else "Error: Version $key Failed:\n"
)

val pathMap = errors.groupBy(ValidationError::elementPath)

pathMap.forEach { (elementPath, validationErrors) ->
stringBuilder
.appendLine("${elementPath.joinToString(" > ")}:")
.appendErrors(validationErrors)
}
}
return stringBuilder.toString()
}

private fun StringBuilder.appendErrors(errors: List<ValidationError>) =
errors.forEach { error: ValidationError ->
when (error) {
is IllegalTagError -> this.appendLine(BULLET + "Illegal Tag: \"${error.tagName}\"")

is RequiredConditionFailedError ->
this.appendLine(BULLET + "Requirement Failed:")
.appendLine(wrapErrorMessage(error.conditionMessage))

is ExpressionSyntaxError ->
this.appendLine(BULLET + "Expression syntax error:")
.appendLine(wrapErrorMessage(error.errorMessage))

is ExpressionVersionEliminationError ->
this.appendLine(BULLET + "Version Eliminated:")
.appendLine(
wrapErrorMessage(
"\"${error.expressionResource}\" is exclusive to versions: " +
error.permittedVersions.joinToString(", ")
)
)

is VersionEliminationError ->
this.appendLine(BULLET + "Version Eliminated:")
.appendLine(
wrapErrorMessage(
"Condition: \"${error.conditionMessage}\" passed which is exclusive to versions: " +
error.permittedVersions.joinToString(", ")
)
)

is IllegalAttributeError ->
this.appendLine(BULLET + "Illegal Attribute: \"${error.attributeName}\"")

is ContentError ->
this.appendLine(BULLET + "Illegal Content:")
.appendLine(wrapErrorMessage(error.errorMessage))

is AttributeValueError ->
this.appendLine(BULLET + "Illegal Attribute Value:")
.appendLine(
wrapErrorMessage(
"Attribute: \"${error.attributeName}\" has illegal value: \"${error.attributeValue}\"."
)
)
.appendLine(wrapErrorMessage(error.errorMessage))

is TagOccurrenceError ->
this.appendLine(BULLET + "Illegal Tag Occurrence:")
.appendLine(
wrapErrorMessage(
"Tag: \"${error.tagName}\" occurs ${error.actualCount} times, but must occur between ${error.expectedRange} times."
)
)

is UnknownError ->
this.appendLine(BULLET + "Unknown error:")
.appendLine(wrapErrorMessage("\"${error.errorMessage}\""))
}
}

/** Helper function for spreading long error messages across multiple lines */
private fun wrapErrorMessage(message: String, wordsPerLine: Int = 15): String {
val words = message.split(" ")
return words.chunked(wordsPerLine).joinToString("") { wordList ->
INDENT + wordList.joinToString(" ") + "\n"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.google.wear.watchface.validator.cli

import com.google.wear.watchface.validator.error.ValidationResult
import java.nio.file.FileSystems
import java.nio.file.Files
import java.nio.file.Path
import java.util.stream.Collectors
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathConstants
import javax.xml.xpath.XPathFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.w3c.dom.Document

internal const val DEFAULT_FORMAT_VERSION = "1"
internal const val MAX_WFF_VERSION = 4
internal const val DWF_SAMPLES = "../samples"

@RunWith(Parameterized::class)
class WatchFaceValidatorSampleTest(val expectedVersions: Set<Int>, val filePath: String) {

companion object {
@JvmStatic
@Parameterized.Parameters(name = "{index}: \"{1}\" = \"{0}\"")
fun testCases(): List<Array<Any>> = findAllSamplesWatchfaceXml()

private fun findAllSamplesWatchfaceXml(): List<Array<Any>> {
val samplesDir = Path.of(DWF_SAMPLES)
Files.walk(samplesDir).use { allFilesInSamples ->
val watchfaceXmlMatcher =
FileSystems.getDefault().getPathMatcher("glob:**/res/raw/watchface*.xml")

return allFilesInSamples
.filter { watchfaceXmlMatcher.matches(it) }
.map { path ->
val associatedManifestPath =
path.resolve("../../../AndroidManifest.xml").normalize()
val minVersion = getDwfVersionFromManifest(associatedManifestPath)
val expectedVersions = (minVersion..MAX_WFF_VERSION).toSet()

arrayOf(expectedVersions, path.toAbsolutePath().normalize().toString())
}
.collect(Collectors.toList())
}
}

private fun getDwfVersionFromManifest(manifestPath: Path): Int {
var version: String = DEFAULT_FORMAT_VERSION
if (Files.exists(manifestPath)) {
val manifest: Document? =
DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(manifestPath.toFile())

val dwfVersionXPath =
XPathFactory.newInstance()
.newXPath()
.compile(
"//property[@name='com.google.wear.watchface.format.version']/@value"
)

val formatVersionOption =
dwfVersionXPath.evaluate(manifest, XPathConstants.STRING) as String?
if (!formatVersionOption.isNullOrEmpty()) {
version = formatVersionOption
}
}

return version.toInt()
}
}

@Test
fun test() {
val result = App.validateRawXml(filePath)
assertTrue(result !is ValidationResult.Failure)
/* Each expected version must have passed. However it is possible for a versioned dwf to be naturally backwards compatible.*/
assertTrue(
"Versions did not match. Expected: $expectedVersions, Found: ${result.validVersions}",
result.validVersions.containsAll(expectedVersions),
)
}
}
Loading
Loading