diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4769ed6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +### Gradle ### +.gradle +build +local.gradle +# See: https://intellij-support.jetbrains.com/entries/23393067-How-to-manage-projects-under-Version-Control-Systems +**/.idea/libraries + +### Java #### +*.class + +### Android SDK ### +bin/ +gen/ +# Local configuration file (sdk path, etc) +local.properties + +### IntelliJ IDEA ### +.idea +*.iml +**/*.iws +**/*.eml +**/.idea/workspace.xml +**/.idea/tasks.xml +**/.idea/misc.xml +/.idea +.iml +out/ +# Proguard folder generated by IntelliJ +proguard_logs/ + + +### Eclipse ### +# project files +.classpath +.project +# Proguard folder generated by Eclipse +proguard/ + +# Temporary stuff +out diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..79cd524 --- /dev/null +++ b/build.gradle @@ -0,0 +1,33 @@ +plugins { + id 'java' + id 'org.jetbrains.kotlin.jvm' +} +apply from: file("../../../versions.gradle") + + +version '1.0-SNAPSHOT' + +sourceCompatibility = 1.8 + +repositories { + mavenCentral() +} + +dependencies { + compile group: 'org.droidmate', name: 'deviceDaemonLib', version: project.ext.driverLib_version + + // we need the jdk dependency instead of stdlib to have enhanced java features like tue 'use' function for try-with-resources + compile group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk8', version: project.ext.kotlin_version + compile "org.jetbrains.kotlin:kotlin-reflect:${project.ext.kotlin_version}" // because we need reflection to get annotated property values + compile "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" + compile 'com.natpryce:konfig:1.6.6.0' // configuration library + + testCompile group: 'junit', name: 'junit', version: '4.12' +} + +compileKotlin { + kotlinOptions.jvmTarget = "1.8" +} +compileTestKotlin { + kotlinOptions.jvmTarget = "1.8" +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..41541a4 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,4 @@ +rootProject.name = "explorationModel" + +includeBuild ('../../deviceCommonLib/deviceDaemonLib') + diff --git a/src/main/kotlin/org/droidmate/explorationModel/Actor.kt b/src/main/kotlin/org/droidmate/explorationModel/Actor.kt new file mode 100644 index 0000000..4564fa3 --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/Actor.kt @@ -0,0 +1,103 @@ +// DroidMate, an automated execution generator for Android apps. +// Copyright (C) 2012-2018. Saarland University +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// Current Maintainers: +// Nataniel Borges Jr. +// Jenny Hotzkow +// +// Former Maintainers: +// Konrad Jamrozik +// +// web: www.droidmate.org + +@file:Suppress("ReplaceSingleLineLet") + +package org.droidmate.explorationModel + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext + +// we have exactly one thread who is going to handle our model actors +// (we use an own thread to avoid blocking issues for mono/duo-cores, where the default thread-pool only has size 1) +private val actorThreadPool = newFixedThreadPoolContext (1,name="actor-thread") +interface Actor: CoroutineScope{ + suspend fun onReceive(msg: E) +} + +/** REMARK: buffered channel currently have some race condition bug when the full capacity is reached. + * In particular, these sender are not properly woken up unless there is a print statement in the receiving loop of this actor + * (which probably eliminates any potential race condition due to sequential console access)*/ +internal inline fun Actor.create() = actor(capacity = Channel.UNLIMITED){ + for (msg in channel) onReceive(msg) +} + +/** in principle adding any element to a collection would be a fast task, however due to the potential timeout for the widget.uid computation + * the state id may be delayed as well and the hash function of Widget and State base on this id. + * Therefore this computation and the set management was taken from the critical path of the execution by using this actor + */ +class CollectionActor(private val actorState: MutableCollection, private val actorName: String): Actor> { + override val coroutineContext: CoroutineContext = actorThreadPool + CoroutineName(actorName) + + override suspend fun onReceive(msg: CollectionMsg){ +// println("[${Thread.currentThread().name}] START msg handling ${msg::class.simpleName}: ${actorState.size}") + when(msg){ + is Add -> actorState.add( msg.elem ) + is AddAll -> actorState.addAll( msg.elements ) + is Get -> msg.response.complete( msg.predicate(actorState) ) + is GetAll -> + if(actorState is Set<*>) msg.response.complete( actorState.toSet() ) + else msg.response.complete( actorState.toList() ) + } //.run{ /* do nothing but keep this .run to ensure when raises compile error if not all sealed class cases are implemented */ } +// println("[${Thread.currentThread().name}] msg handling ${msg::class.simpleName}: ${actorState.size}") + } + override fun toString(): String = actorName +} +@kotlin.Suppress("unused") +sealed class CollectionMsg + +class Add(val elem: T): CollectionMsg() +class AddAll(val elements: Collection): CollectionMsg() +class GetAll(val response: CompletableDeferred): CollectionMsg() +/** this method allows to retrieve a specific element as determined by @predicate, + * however the predicate should only contain very cheap computations as otherwise the WHOLE exploration performance would suffer. + * For more expensive computations please retrieve the whole collection via [GetAll] and perform your operation on the result + */ +class Get(inline val predicate:(Collection)->R, val response: CompletableDeferred): CollectionMsg() + +/** this method allows to retrieve a specific element as determined by @predicate, + * however the predicate should only contain very cheap computations as otherwise the WHOLE exploration performance would suffer. + * For more expensive computations please retrieve the whole collection via [getAll] and perform your operation on the result + */ +suspend inline fun SendChannel>.getOrNull(noinline predicate:(Collection)->R): R? + = this.let{actor -> with(CompletableDeferred()){ actor.send(Get(predicate, this)); this.await()} } + +/** this method allows to retrieve a specific element as determined by @predicate, + * however the predicate should only contain very cheap computations as otherwise the WHOLE exploration performance would suffer. + * For more expensive computations please retrieve the whole collection via [getAll] and perform your operation on the result + */ +suspend inline fun SendChannel>.get(noinline predicate:(Collection)->R): R + = this.let{actor -> with(CompletableDeferred()){ actor.send(Get(predicate, this)); this.await()} }!! + +/** @return a copy of the actors current private collection (actorState) */ +suspend inline fun SendChannel>.getAll(): R + = this.let{actor -> with(CompletableDeferred()){ actor.send(GetAll(this)); this.await()} } + +/** @return (blocking) a copy of the actors current private collection (actorState). + * !!! This method should not be called from coroutine */ +inline fun SendChannel>.S_getAll(): R + = this.let{actor -> with(CompletableDeferred()){ actor.sendBlocking(GetAll(this)); runBlocking {await()}} } diff --git a/src/main/kotlin/org/droidmate/explorationModel/ExplorationTrace.kt b/src/main/kotlin/org/droidmate/explorationModel/ExplorationTrace.kt new file mode 100644 index 0000000..2c136bb --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/ExplorationTrace.kt @@ -0,0 +1,223 @@ +// DroidMate, an automated execution generator for Android apps. +// Copyright (C) 2012-2018. Saarland University +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// Current Maintainers: +// Nataniel Borges Jr. +// Jenny Hotzkow +// +// Former Maintainers: +// Konrad Jamrozik +// +// web: www.droidmate.org + +@file:Suppress("FunctionName") + +package org.droidmate.explorationModel + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.sendBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.droidmate.deviceInterface.exploration.* +import org.droidmate.explorationModel.config.* +import org.droidmate.explorationModel.config.ConfigProperties.ModelProperties.dump.sep +import org.droidmate.explorationModel.interaction.* +import org.droidmate.explorationModel.retention.StringCreator +import org.droidmate.explorationModel.retention.StringCreator.createActionString +import java.io.File +import java.util.* +import kotlin.collections.HashSet +import kotlin.properties.Delegates + +@Suppress("MemberVisibilityCanBePrivate") +open class ExplorationTrace(private val watcher: MutableList = mutableListOf(), private val config: ModelConfig, val id: UUID) { + protected val dumpMutex = Mutex() // for synchronization of (trace-)file access + init{ widgetTargets.clear() // ensure that this list is cleared even if we had an exception on previous apk exploration + } + + protected val trace = CollectionActor(LinkedList(), "TraceActor").create() + + private val targets: MutableList = LinkedList() + /** store all text values we inserted into input fields. This can be used to recognize fields we already acted upon */ + private val textInsers = HashSet() + + data class RecentState(val state: State, val interactionTargets: List, val action: ExplorationAction, val interactions: List) + /** this property is set in the end of the trace update and notifies all watchers for changes */ + protected var mostRecentState: RecentState + by Delegates.observable(RecentState(State.emptyState, emptyList(), EmptyAction, emptyList())) { _, last, recent -> + notifyObserver(old = last.state, new = recent.state, targets = recent.interactionTargets, + explorationAction = recent.action, interactions = recent.interactions) + internalUpdate(srcState = last.state, interactedTargets = recent.interactionTargets, interactions = recent.interactions) + } + private set + + + /** observable delegates do not support co-routines within the lambda function therefore this method*/ + protected open fun notifyObserver(old: State, new: State, targets: List, explorationAction: ExplorationAction, interactions: List) { + watcher.forEach { + it.launch { it.onNewInteracted(id, targets, old, new) } + val actionIndex = size - 1 + assert(actionIndex >= 0){"ERROR the action-trace size was not properly updated"} + it.launch { it.onNewInteracted(id, actionIndex, explorationAction, targets, old, new) } + + it.launch { it.onNewAction(id, interactions, old, new) } + } + } + + /** used to keep track of all widgets interacted with, i.e. the edit fields which require special care in uid computation */ + protected open fun internalUpdate(srcState: State, interactedTargets: List, interactions: List) { + interactions.forEach { + if(it.actionType.isTextInsert()) textInsers.add(it.data) // keep track of all inserted text values + } + this.targets.addAll(interactedTargets) + } + + private fun actionProcessor(actionRes: ActionResult, oldState: State, dstState: State): List = LinkedList().apply{ + if(widgetTargets.isNotEmpty()) + assert(oldState.widgets.containsAll(widgetTargets)) {"ERROR on ExplorationTrace generation, tried to add action for widgets $widgetTargets which do not exist in the source state $oldState"} + + if(actionRes.action is ActionQueue) + actionRes.action.actions.map { + Interaction(it, res = actionRes, prevStateId = oldState.stateId, resStateId = dstState.stateId, + target = if(actionRes.action.hasWidgetTarget) widgetTargets.pollFirst() else null) + }.also { + add(Interaction(ActionQueue.startName, res = actionRes, prevStateId = oldState.stateId, resStateId = dstState.stateId)) + addAll(it) + add(Interaction(ActionQueue.endName, res = actionRes, prevStateId = oldState.stateId, resStateId = dstState.stateId)) + } + else + add( Interaction(res = actionRes, prevStateId = oldState.stateId, resStateId = dstState.stateId, + target = if(actionRes.action.hasWidgetTarget) widgetTargets.pollFirst() else null) ) + }.also { interactions -> + widgetTargets.clear() + P_addAll(interactions) + } + + /*************** public interface ******************/ + + fun update(action: ActionResult, dstState: State) { + size += 1 + lastActionType = if(action.action is ActionQueue) action.action.actions.lastOrNull()?.name ?:"empty queue" else action.action.name + // we did not update this.dstState yet, therefore it contains the now 'old' state + + val actionTargets = widgetTargets.toList() // we may have an action queue and therefore multiple targets in the same state + val interactions = debugT("action processing", {actionProcessor(action, this.mostRecentState.state, dstState)},inMillis = true) + + debugT("set dstState", { this.mostRecentState = ExplorationTrace.RecentState(dstState, actionTargets, action.action, interactions) }) + } + + fun addWatcher(mf: ModelFeatureI) = watcher.add(mf) + + /** this function is used by the ModelLoader which creates Interaction objects from dumped data + * this function is purposely not called for the whole Interaction set, such that we can issue all watcher updates + * if no watchers are registered use [updateAll] instead + * ASSUMPTION only one co-routine is simultaneously working on this ExplorationTrace object*/ + internal suspend fun update(action: Interaction, dstState: State) { + size += 1 + lastActionType = action.actionType + trace.send(Add(action)) + this.mostRecentState = RecentState(dstState, widgetTargets, EmptyAction, listOf(action)) + } + + /** this function is used by the ModelLoader which creates Interaction objects from dumped data + * to update the whole trace at once + * ASSUMPTION no watchers are to be notified + */ + internal suspend fun updateAll(actions: List, latestState: State){ + size += actions.size + lastActionType = actions.last().actionType + trace.send(AddAll(actions)) + if(actions.last().actionType.isQueueEnd()){ + val queueStart = actions.indexOfLast { it.actionType.isQueueStart() } + val interactions = actions.subList(queueStart,actions.size) + this.mostRecentState = RecentState(latestState, interactions.mapNotNull { it.targetWidget }, EmptyAction, interactions) + }else this.mostRecentState = RecentState(latestState, listOfNotNull(actions.last().targetWidget), EmptyAction, listOf(actions.last())) + } + + val currentState get() = mostRecentState.state + var size: Int = 0 // avoid timeout from trace access and just count how many actions were created + var lastActionType: String = "" + + @Deprecated("to be removed, instead have a list of all unexplored widgets and remove the ones chosen as target -> best done as ModelFeature") + fun unexplored(candidates: List): List = candidates.filterNot { w -> + targets.any { w.uid == it.uid } + } + + fun insertedTextValues(): Set = textInsers + + fun getExploredWidgets(): List = targets + + private fun P_addAll(actions:List) = trace.sendBlocking(AddAll(actions)) // this does never actually block the sending since the capacity is unlimited + + /** use this function only on the critical execution path otherwise use [P_getActions] instead */ + fun getActions(): List //FIXME the runBlocking should be replaced with non-thread blocking coroutineScope + = runBlocking{coroutineScope> { // -> requires suspend propagation in selectors which are lambda values + return@coroutineScope trace.S_getAll() + } } + @Suppress("MemberVisibilityCanBePrivate") + /** use this method within co-routines to make complete use of suspendable feature */ + suspend fun P_getActions(): List{ + return trace.getAll() + } + + suspend fun last(): Interaction? { + return trace.getOrNull { it.lastOrNull() } + } + + /** this has to access a co-routine actor prefer using [size] if synchronization is not critical */ + suspend fun isEmpty(): Boolean{ + return trace.get { it.isEmpty() } + } + /** this has to access a co-routine actor prefer using [size] if synchronization is not critical */ + suspend fun isNotEmpty(): Boolean{ + return trace.get { it.isNotEmpty() } + } + + /** this process is not waiting for the currently processed action, therefore this method should be only + * used if at least 2 actions were already executed. Otherwise you should prefer 'getAt(0)' + */ + suspend fun first(): Interaction = trace.getOrNull { it.first() } ?: Interaction.empty + + //TODO ensure that the latest dump is not overwritten due to scheduling issues, for example by using a nice buffered channel only keeping the last value offer + open suspend fun dump(config: ModelConfig = this.config) = dumpMutex.withLock { + File(config.traceFile(id.toString())).bufferedWriter().use { out -> + out.write(StringCreator.actionHeader(config[sep])) + out.newLine() + // ensure that our trace is complete before dumping it by calling blocking getActions + P_getActions().forEach { action -> + out.write(createActionString(action,config[sep])) + out.newLine() + } + } + } + + override fun equals(other: Any?): Boolean { + return(other as? ExplorationTrace)?.let { + val t = other.getActions() + getActions().foldIndexed(true) { i, res, a -> res && a == t[i] } + } ?: false + } + + override fun hashCode(): Int { + return trace.hashCode() + } + + companion object { + val widgetTargets = LinkedList() + } + +} + diff --git a/src/main/kotlin/org/droidmate/explorationModel/Model.kt b/src/main/kotlin/org/droidmate/explorationModel/Model.kt new file mode 100644 index 0000000..cd74c31 --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/Model.kt @@ -0,0 +1,234 @@ +// DroidMate, an automated execution generator for Android apps. +// Copyright (C) 2012-2018. Saarland University +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// Current Maintainers: +// Nataniel Borges Jr. +// Jenny Hotzkow +// +// Former Maintainers: +// Konrad Jamrozik +// +// web: www.droidmate.org + +package org.droidmate.explorationModel + +import kotlinx.coroutines.* +import org.droidmate.deviceInterface.exploration.UiElementPropertiesI +import org.droidmate.explorationModel.config.ConfigProperties +import org.droidmate.explorationModel.config.ModelConfig +import org.droidmate.explorationModel.interaction.* +import org.droidmate.explorationModel.retention.loading.ModelParser +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.nio.file.Files +import java.nio.file.StandardOpenOption +import java.util.* +import kotlin.coroutines.CoroutineContext +import kotlin.system.measureTimeMillis + + +/** + * we implement CoroutineScope this allows us to implicitly wait for all child jobs (launch/async) started in this scope + * any direct call of launch/async is in this scope and will be waited for at the end of this lifecycle (end of exploration) + * meanwhile we use currentScope or coroutineScope/supervisorScope to directly wait for child jobs before returning from a function call + */ +open class Model private constructor(val config: ModelConfig): CoroutineScope { + + private val paths = LinkedList() + /** non-mutable view of all traces contained within this model */ + fun getPaths(): List = paths + +/**---------------------------------- public interface --------------------------------------------------------------**/ + open fun initNewTrace(watcher: LinkedList,id: UUID = UUID.randomUUID()): ExplorationTrace { + return ExplorationTrace(watcher, config, id).also { actionTrace -> + paths.add(actionTrace) + } + } + + // we use supervisorScope for the dumping, such that cancellation and exceptions are only propagated downwards + // meaning if a dump process fails the overall model process is not affected + open suspend fun dumpModel(config: ModelConfig): Job = this.launch(CoroutineName("Model-dump")+backgroundJob){ + getStates().let { states -> + debugOut("dump Model with ${states.size}") + states.forEach { s -> launch(CoroutineName("state-dump ${s.uid}")) { s.dump(config) } } + } + paths.forEach { t -> launch(CoroutineName("trace-dump")) { t.dump(config) } } + } + + private var uTime: Long = 0 + /** update the model with any [action] executed as part of an execution [trace] **/ + suspend fun updateModel(action: ActionResult, trace: ExplorationTrace) { + measureTimeMillis { + storeScreenShot(action) + val widgets = generateWidgets(action, trace).also{ addWidgets(it) } + val newState = generateState(action, widgets).also{ addState(it) } + trace.update(action, newState) + + if (config[ConfigProperties.ModelProperties.dump.onEachAction]) { + this.launch(CoroutineName("state-dump")) { newState.dump(config) } //TODO the launch may be on state/trace object instead + this.launch(CoroutineName("trace-dump")) { trace.dump(config) } + } + }.let { + debugOut("model update took $it millis") + uTime += it + debugOut("---------- average model update time ${uTime / trace.size} ms overall ${uTime / 1000.0} seconds --------------") + } + } + + /**--------------------------------- concurrency utils ------------------------------------------------------------**/ + //we need + Job() to be able to CancelAndJoin this context, otherwise we can ONLY cancel this scope or its children + override val coroutineContext: CoroutineContext = CoroutineName("ModelScope")+Job() //we do not define a dispatcher, this means Dispatchers.Default is automatically used (a pool of worker threads) + + /** this job can be used for any coroutine context which is not essential for the main model process. + * In particular we use it to invoke background processes for model or img dump + */ + @Suppress("MemberVisibilityCanBePrivate") + protected val backgroundJob = SupervisorJob() + /** This will notify all children that this scope is to be canceled (which is an cooperative mechanism, mechanism all non-terminating spawned children have to check this flag). + * Moreover, this will propagate the cancellation to our model-actors and join all structural child coroutines of this scope. + */ + suspend fun cancelAndJoin() = coroutineContext[Job]!!.cancelAndJoin() + + private val states = CollectionActor(HashSet(), "StateActor").create() + /** @return a view to the data (suspending function) */ + suspend fun getStates(): Set = states.getAll() + + /** should be used only by model loader/parser */ + internal suspend fun addState(s: State){ + nStates +=1 + states.send(Add(s)) + } + + suspend fun getState(id: ConcreteId): State?{ + val states = getStates() + return states.find { it.stateId == id } + } + + private val widgets = CollectionActor(HashSet(), "WidgetActor").create() + + suspend fun getWidgets(): Set{ //TODO instead we could have the list of seen interactive widgets here (potentially with the count of interactions) + return CompletableDeferred>().let{ response -> + widgets.send(GetAll(response)) + response.await() as Set + } + } + + /** adding a value to the actor is non blocking and should not take much time */ + internal suspend fun addWidgets(w: Collection) { + nWidgets += w.size + widgets.send(AddAll(w)) + } + + /** -------------------------------------- protected generator methods --------------------------------------------**/ + + /** used on model update to instantiate a new state for the current UI screen */ + protected open fun generateState(action: ActionResult, widgets: Collection): State = + with(action.guiSnapshot) { State(widgets, isHomeScreen) } + + /** used by ModelParser to create [State] object from persisted data */ + internal open fun parseState(widgets: Collection, isHomeScreen: Boolean): State = + State(widgets, isHomeScreen) + + private fun generateWidgets(action: ActionResult, @Suppress("UNUSED_PARAMETER") trace: ExplorationTrace): Collection{ + val elements: Map = action.guiSnapshot.widgets.associateBy { it.idHash } + return generateWidgets(elements) + } + + /** used on model update to compute the list of UI elements contained in the current UI screen ([State]). + * used by ModelParser to create [Widget] object from persisted data + */ + internal open fun generateWidgets(elements: Map): Collection{ + val widgets = HashMap() + val workQueue = LinkedList().apply { + addAll(elements.values.filter { it.parentHash == 0 }) // add all roots to the work queue + } + check(elements.isEmpty() || workQueue.isNotEmpty()){"ERROR we don't have any roots something went wrong on UiExtraction"} + while (workQueue.isNotEmpty()){ + with(workQueue.pollFirst()){ + val parent = if(parentHash != 0) widgets[parentHash]!!.id else null + widgets[idHash] = Widget(this, parent) + childHashes.forEach { +// check(elements[it]!=null){"ERROR no element with hashId $it in working queue"} + if(elements[it] == null) + logger.warn("could not find child with id $it of widget $this ") + else workQueue.add(elements[it]!!) } //FIXME if null we can try to find element.parentId = this.idHash !IN workQueue as repair function, but why does it happen at all + } + } + check(widgets.size==elements.size){"ERROR not all UiElements were generated correctly in the model ${elements.filter { !widgets.containsKey(it.key) }.values}"} + assert(elements.all { e -> widgets.values.any { it.idHash == e.value.idHash } }){ "ERROR not all UiElements were generated correctly in the model ${elements.filter { !widgets.containsKey(it.key) }}" } + return widgets.values + } + + /**---------------------------------------- private methods -------------------------------------------------------**/ + private fun storeScreenShot(action: ActionResult) = this.launch(CoroutineName("screenShot-dump")+backgroundJob){ + if(action.screenshot.isNotEmpty()) + Files.write(config.imgDst.resolve("${action.action.id}.jpg"), action.screenshot + , StandardOpenOption.CREATE, StandardOpenOption.WRITE) + } + + companion object { + val logger: Logger by lazy { LoggerFactory.getLogger(this::class.java) } + @JvmStatic + fun emptyModel(config: ModelConfig): Model = Model(config).apply { runBlocking { addState(State.emptyState) }} + + /** + * use this method to load a specific app model from its dumped data + * + * example: + * val test = loadAppModel("ch.bailu.aat") + * runBlocking { println("$test #widgets=${test.getWidgets().size} #states=${test.getStates().size} #paths=${test.getPaths().size}") } + */ + @Suppress("unused") + @JvmStatic fun loadAppModel(appName: String, watcher: LinkedList = LinkedList()) + = runBlocking { ModelParser.loadModel(ModelConfig(appName = appName, isLoadC = true), watcher) } + + /** debug method **/ + @JvmStatic + fun main(args: Array) { + + println("runBlocking: ${Thread.currentThread()}") + + val t = Model.emptyModel(ModelConfig("someApp")) + t.launch { + println("ModelScope.launch: ${Thread.currentThread()}") + } + t.coroutineContext.cancel() + val active = t.isActive + println(active) + +// val test = ModelParser.loadModel(ModelConfig(path = Paths.get("src/main", "out", "playback"), appName = "testModel", isLoadC = true))//loadAppModel("loadTest") +// runBlocking { println("$test #widgets=${test.getWidgets().size} #states=${test.getStates().size} #paths=${test.getPaths().size}") } +// test.getPaths().first().getActions().forEach { a -> +// println("ACTION: " + a.actionString()) +// } + } + + } /** end COMPANION **/ + + /*********** debugging parameters *********************************/ + /** debugging counter do not use it in productive code, instead access the respective element set */ + private var nWidgets = 0 + /** debugging counter do not use it in productive code, instead access the respective element set */ + private var nStates = 0 + + /** + * this only shows how often the addState or addWidget function was called, but if identical id's were added multiple + * times the real set will contain less elements then these counter indicate + */ + override fun toString(): String { + return "Model[#addState=$nStates, #addWidget=$nWidgets, paths=${paths.size}]" + } +} diff --git a/src/main/kotlin/org/droidmate/explorationModel/ModelFeatureI.kt b/src/main/kotlin/org/droidmate/explorationModel/ModelFeatureI.kt new file mode 100644 index 0000000..3922ac2 --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/ModelFeatureI.kt @@ -0,0 +1,105 @@ +// DroidMate, an automated execution generator for Android apps. +// Copyright (C) 2012-2018. Saarland University +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// Current Maintainers: +// Nataniel Borges Jr. +// Jenny Hotzkow +// +// Former Maintainers: +// Konrad Jamrozik +// +// web: www.droidmate.org + +package org.droidmate.explorationModel + +import kotlinx.coroutines.* +import org.droidmate.deviceInterface.exploration.ExplorationAction +import org.droidmate.explorationModel.interaction.Interaction +import org.droidmate.explorationModel.interaction.State +import org.droidmate.explorationModel.interaction.Widget +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.util.* +import kotlin.coroutines.CoroutineContext + +/** + * this class contains three different observer methods to keep track of model changes. + * the Feature should try to follow the least principle policy and prefer [onNewInteracted] over the alternatives + * onAppExplorationFinished are calling cancel and join so your function should override it if any data is to be persisted + */ +@Suppress("unused", "UNUSED_ANONYMOUS_PARAMETER") +abstract class ModelFeatureI : CoroutineScope { + + companion object { + @JvmStatic + val log: Logger by lazy { LoggerFactory.getLogger(ModelFeatureI::class.java) } + } + + /** may be used in the strategy to ensure that the updating coroutine function already finished. + * your ModelFeature should override this function if it requires any synchronization + * + * WARNING: this method should not be called form within the model feature (unless running in a different job), otherwise it will cause a deadlock + */ + open suspend fun join() = coroutineContext[Job]?.children?.forEach { it.join() } // we do not join the parent job as it usually does not complete until this feature is canceled + + open suspend fun cancelAndJoin() { if(coroutineContext.isActive) coroutineContext[Job]?.cancelAndJoin() } + + /** the eContext in which the update tasks of the class are going to be started, + * for performance reasons they should run within the same pool for each feature + * e.g. `newCoroutineContext(context = CoroutineName("FeatureNameMF"), parent = job)` + * or you can use `newSingleThreadContext("MyOwnThread")` to ensure that your update methods get its own thread + * However, you should not use the main thread dispatcher or you may end up in deadlock situations. + * (Simply using the default dispatcher is fine) + */ + abstract override val coroutineContext: CoroutineContext + + /** called whenever an action or actionqueue was executed on [targetWidgets] the device resulting in [newState] + * this function may be used instead of update for simpler access to the action and result state. + * The [targetWidgets] belong to the actions with hasWidgetTarget = true and are in the same order as they appeared + * in the actionqueue. + **/ + open suspend fun onNewInteracted(traceId: UUID, targetWidgets: List, prevState: State, newState: State) { /* do nothing [to be overwritten] */ + } + + /** called whenever an action or actionqueue was executed on [targetWidgets] the device resulting in [newState] + * this function may be used instead of update for simpler access to the action and result state. + * The [targetWidgets] belong to the actions with hasWidgetTarget = true and are in the same order as they appeared + * in the actionqueue. + * + * WARNING: this method only gets `EmptyAction` when loading an already existing model + **/ + open suspend fun onNewInteracted(traceId: UUID, actionIdx: Int, action: ExplorationAction, + targetWidgets: List, prevState: State, newState: State) { + /* do nothing [to be overwritten] */ + } + + // TODO check if an additional method with (targets,actions:ExplorationAction) would prove useful + + /** called whenever a new action was executed on the device resulting in [newState] + * this function may be used instead of update for simpler access to the action and result state. + * + * If possible the use of [onNewInteracted] should be preferred instead, since the action computation may introduce an additional timeout to this computation. Meanwhile [onNewInteracted] is directly ready to run.*/ + open suspend fun onNewAction(traceId: UUID, interactions: List, prevState: State, newState: State) { /* do nothing [to be overwritten] */ + } + + /** can be used to persist any data during Exploration whenever ExplorationContext.dump is called. + * The exploration never waits for this method to complete, as it is launched in an independent scope. + * Therefore, it is your features responsibility to guarantee that your last state is persisted, e.g. by implementing [cancelAndJoin]. + */ + open suspend fun dump() { + } + +} \ No newline at end of file diff --git a/src/main/kotlin/org/droidmate/explorationModel/config/ConfigProperties.kt b/src/main/kotlin/org/droidmate/explorationModel/config/ConfigProperties.kt new file mode 100644 index 0000000..356b436 --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/config/ConfigProperties.kt @@ -0,0 +1,39 @@ +package org.droidmate.explorationModel.config + +import com.natpryce.konfig.* + +object ConfigProperties { + internal object Output : PropertyGroup() { + val outputDir by uriType + } + + object ModelProperties : PropertyGroup() { + object path : PropertyGroup() { + val defaultBaseDir by uriType + val statesSubDir by uriType + val imagesSubDir by uriType + val cleanDirs by booleanType + } + + object dump : PropertyGroup() { + val sep by stringType + val onEachAction by booleanType + + val stateFileExtension by stringType + + val traceFileExtension by stringType + val traceFilePrefix by stringType + } + + object imgDump : PropertyGroup() { + val states by booleanType + val widgets by booleanType + + object widget : PropertyGroup() { + val nonInteractable by booleanType + val interactable by booleanType + val onlyWhenNoText by booleanType + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/droidmate/explorationModel/config/ModelConfig.kt b/src/main/kotlin/org/droidmate/explorationModel/config/ModelConfig.kt new file mode 100644 index 0000000..322f4a4 --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/config/ModelConfig.kt @@ -0,0 +1,98 @@ +// DroidMate, an automated execution generator for Android apps. +// Copyright (C) 2012-2018. Saarland University +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// Current Maintainers: +// Nataniel Borges Jr. +// Jenny Hotzkow +// +// Former Maintainers: +// Konrad Jamrozik +// +// web: www.droidmate.org + +package org.droidmate.explorationModel.config + +import com.natpryce.konfig.* +import org.droidmate.explorationModel.ConcreteId +import org.droidmate.explorationModel.config.ConfigProperties.ModelProperties.dump.stateFileExtension +import org.droidmate.explorationModel.config.ConfigProperties.ModelProperties.dump.traceFileExtension +import org.droidmate.explorationModel.config.ConfigProperties.ModelProperties.dump.traceFilePrefix +import org.droidmate.explorationModel.config.ConfigProperties.ModelProperties.path.cleanDirs +import org.droidmate.explorationModel.config.ConfigProperties.ModelProperties.path.defaultBaseDir +import org.droidmate.explorationModel.config.ConfigProperties.ModelProperties.path.statesSubDir +import org.droidmate.explorationModel.config.ConfigProperties.ModelProperties.path.imagesSubDir +import org.droidmate.explorationModel.config.ConfigProperties.Output.outputDir +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.* + +class ModelConfig private constructor(path: Path, + val appName: String, + private val config: Configuration, + isLoadC: Boolean = false) : Configuration by config { + /** @path path-string locationg the base directory where all model data is supposed to be dumped */ + + constructor(path: Path, appName: String, isLoadC: Boolean = false): this(path.toAbsolutePath(), appName, resourceConfig, isLoadC) + + val baseDir: Path = path.resolve(appName) // directory path where the model file(s) should be stored + val stateDst: Path = baseDir.resolve(config[statesSubDir].path) // each state gets an own file named according to UUID in this directory + val imgDst: Path = baseDir.resolve(config[imagesSubDir].path) // the images for the app widgets are stored in this directory (for report/debugging purpose only) + + init { // initialize directories (clear them if cleanDirs is enabled) + if (!isLoadC){ + if (config[cleanDirs]) (baseDir).toFile().deleteRecursively() + Files.createDirectories((baseDir)) + Files.createDirectories((stateDst)) + Files.createDirectories((imgDst)) + } + } + + private val idPath: (Path, String, String, String) -> String = { baseDir, id, postfix, fileExtension -> baseDir.toString() + "${File.separator}$id$postfix$fileExtension" } + + val widgetFile: (ConcreteId, Boolean) -> String = { id, isHomeScreen -> + statePath(id, postfix = (if(isHomeScreen) "_HS" else "") ) } + fun statePath(id: ConcreteId, postfix: String = "", fileExtension: String = config[stateFileExtension]): String { + return idPath(stateDst, id.toString(), postfix, fileExtension) + } + + @Deprecated("to be removed") + fun widgetImgPath(id: UUID, postfix: String = "", fileExtension: String = ".png", interactive: Boolean): String { + val baseDir = if (interactive) imgDst else imgDst.resolve(nonInteractiveDir) + return idPath(baseDir, id.toString(), postfix, fileExtension) + } + + val traceFile = { traceId: String -> "$baseDir${File.separator}${config[traceFilePrefix]}$traceId${config[traceFileExtension]}" } + + companion object { + private const val nonInteractiveDir = "widgets-nonInteractive" + + private val resourceConfig by lazy { + ConfigurationProperties.fromResource("runtime/defaultModelConfig.properties") + } + + @JvmOverloads operator fun invoke(appName: String, isLoadC: Boolean = false, cfg: Configuration? = null): ModelConfig { + val (config, path) = if (cfg != null) + Pair(cfg overriding resourceConfig, Paths.get(cfg[outputDir].toString()).resolve("model")) + else + Pair(resourceConfig, Paths.get(resourceConfig[defaultBaseDir].toString())) + + return ModelConfig(path, appName, config, isLoadC) + } + + } /** end COMPANION **/ +} diff --git a/src/main/kotlin/org/droidmate/explorationModel/interaction/ActionResult.kt b/src/main/kotlin/org/droidmate/explorationModel/interaction/ActionResult.kt new file mode 100644 index 0000000..cee1b13 --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/interaction/ActionResult.kt @@ -0,0 +1,67 @@ +// DroidMate, an automated execution generator for Android apps. +// Copyright (C) 2012-2018. Saarland University +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// Current Maintainers: +// Nataniel Borges Jr. +// Jenny Hotzkow +// +// Former Maintainers: +// Konrad Jamrozik +// +// web: www.droidmate.org + +package org.droidmate.explorationModel.interaction + +import org.droidmate.deviceInterface.exploration.DeviceResponse +import org.droidmate.deviceInterface.exploration.EmptyAction +import org.droidmate.deviceInterface.exploration.ExplorationAction +import java.io.Serializable +import java.time.LocalDateTime + +/** + * Interface for a eContext record which stores the performed action, alongside the GUI state before the action + * + * this should be only used for state model instantiation and not for exploration strategies + * + * @param action ExplorationAction which was sent (by the ExplorationStrategy) to DroidMate + * @param startTimestamp Time the action selection started (used to sync logcat) + * @param endTimestamp Time the action selection started (used to sync logcat) + * @param deviceLogs APIs triggered by this action + * @param guiSnapshot Device snapshot after executing the action + * @param exception Exception during execution which crashed the action (if any), or MissingDeviceException (otherwise) + * @param screenshot Path to the screenshot (taken after the action was executed) + *expl + * @author Nataniel P. Borges Jr. + */ +class ActionResult(val action: ExplorationAction, + val startTimestamp: LocalDateTime, + val endTimestamp: LocalDateTime, + val deviceLogs: List = emptyList(), + val guiSnapshot: DeviceResponse = DeviceResponse.empty, + val screenshot: ByteArray = ByteArray(0), + val exception: String = "") : Serializable { + companion object { + private const val serialVersionUID: Long = 1 + } + + /** + * Identifies if the action was successful or crashed + */ + val successful: Boolean + get() = exception == "N/A (no device exception available)" //fixme this should probably be a const in common lib + +} +val EmptyActionResult = ActionResult(EmptyAction, LocalDateTime.MIN, LocalDateTime.MIN) diff --git a/src/main/kotlin/org/droidmate/explorationModel/interaction/Interaction.kt b/src/main/kotlin/org/droidmate/explorationModel/interaction/Interaction.kt new file mode 100644 index 0000000..ba83dc0 --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/interaction/Interaction.kt @@ -0,0 +1,115 @@ +package org.droidmate.explorationModel.interaction + +import org.droidmate.deviceInterface.communication.TimeFormattedLogMessageI +import org.droidmate.deviceInterface.exploration.* +import org.droidmate.explorationModel.ConcreteId +import org.droidmate.explorationModel.emptyId +import org.droidmate.explorationModel.retention.StringCreator +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +typealias DeviceLog = TimeFormattedLogMessageI +typealias DeviceLogs = List + +@Suppress("DataClassPrivateConstructor") +open class Interaction ( + @property:Persistent("Action", 1) val actionType: String, + @property:Persistent("Interacted Widget", 2, PType.ConcreteId) val targetWidget: Widget?, + @property:Persistent("StartTime", 4, PType.DateTime) val startTimestamp: LocalDateTime, + @property:Persistent("EndTime", 5, PType.DateTime) val endTimestamp: LocalDateTime, + @property:Persistent("SuccessFul", 6, PType.Boolean) val successful: Boolean, + @property:Persistent("Exception", 7) val exception: String, + @property:Persistent("Source State", 0, PType.ConcreteId) val prevState: ConcreteId, + @property:Persistent("Resulting State", 3, PType.ConcreteId) val resState: ConcreteId, + @property:Persistent("Data", 8) val data: String = "", + val deviceLogs: DeviceLogs = emptyList(), + @Suppress("unused") val meta: String = "") { + + constructor(res: ActionResult, prevStateId: ConcreteId, resStateId: ConcreteId, target: Widget?) + : this(actionType = res.action.name, targetWidget = target, + startTimestamp = res.startTimestamp, endTimestamp = res.endTimestamp, successful = res.successful, + exception = res.exception, prevState = prevStateId, resState = resStateId, data = computeData(res.action), + deviceLogs = res.deviceLogs, meta = res.action.id.toString()) + + /** used for ActionQueue entries */ + constructor(action: ExplorationAction, res: ActionResult, prevStateId: ConcreteId, resStateId: ConcreteId, target: Widget?) + : this(action.name, target, res.startTimestamp, + res.endTimestamp, successful = res.successful, exception = res.exception, prevState = prevStateId, + resState = resStateId, data = computeData(action), deviceLogs = res.deviceLogs) + + /** used for ActionQueue start/end Interaction */ + internal constructor(actionName:String, res: ActionResult, prevStateId: ConcreteId, resStateId: ConcreteId) + : this(actionName, null, res.startTimestamp, + res.endTimestamp, successful = res.successful, exception = res.exception, prevState = prevStateId, + resState = resStateId, deviceLogs = res.deviceLogs) + + /** used for parsing from string */ + constructor(actionType: String, target: Widget?, startTimestamp: LocalDateTime, endTimestamp: LocalDateTime, + successful: Boolean, exception: String, resState: ConcreteId, prevState: ConcreteId, data: String = "") + : this(actionType = actionType, targetWidget = target, startTimestamp = startTimestamp, endTimestamp = endTimestamp, + successful = successful, exception = exception, prevState = prevState, resState = resState, data = data) + + + /** + * Time the strategy pool took to select a strategy and a create an action + * (used to measure overhead for new exploration strategies) + */ + val decisionTime: Long by lazy { ChronoUnit.MILLIS.between(startTimestamp, endTimestamp) } + + @JvmOverloads + @Deprecated("to be removed", ReplaceWith("StringCreator.createActionString(a: Interaction, sep: String)")) + fun actionString(chosenFields: Array = ActionDataFields.values(), sep: String = ";"): String = chosenFields.joinToString(separator = sep) { + when (it) { + ActionDataFields.Action -> actionType + ActionDataFields.StartTime -> startTimestamp.toString() + ActionDataFields.EndTime -> endTimestamp.toString() + ActionDataFields.Exception -> exception + ActionDataFields.SuccessFul -> successful.toString() + ActionDataFields.PrevId -> prevState.toString() + ActionDataFields.DstId -> resState.toString() + ActionDataFields.WId -> targetWidget?.id.toString() + ActionDataFields.Data -> data + } + } + + companion object { + + @JvmStatic val actionTypeIdx = StringCreator.actionProperties.indexOfFirst { it.property == Interaction::actionType } + @JvmStatic val widgetIdx = StringCreator.actionProperties.indexOfFirst { it.property == Interaction::targetWidget } + @JvmStatic val resStateIdx = StringCreator.actionProperties.indexOfFirst { it.property == Interaction::resState } + @JvmStatic val srcStateIdx = StringCreator.actionProperties.indexOfFirst { it.property == Interaction::prevState } + + @JvmStatic + fun computeData(e: ExplorationAction):String = when(e){ + is TextInsert -> e.text + is Swipe -> "${e.start.first},${e.start.second} TO ${e.end.first},${e.end.second}" + is RotateUI -> e.rotation.toString() + else -> "" + } + + @JvmStatic + val empty: Interaction by lazy { + Interaction("EMPTY", null, LocalDateTime.MIN, LocalDateTime.MIN, true, + "root action", emptyId, prevState = emptyId) + } + + @Deprecated("to be removed in next version") + enum class ActionDataFields(var header: String = "") { PrevId("Source State"), Action, WId("Interacted Widget"), + DstId("Resulting State"), StartTime, EndTime, SuccessFul, Exception, Data; + + init { + if (header == "") header = name + } + } + } + + override fun toString(): String { + @Suppress("ReplaceSingleLineLet") + return "$actionType: widget[${targetWidget?.let { it.toString() }}]:\n$prevState->$resState" + } + + fun copy(prevState: ConcreteId, resState: ConcreteId): Interaction + = Interaction(actionType = actionType, targetWidget = targetWidget, startTimestamp = startTimestamp, + endTimestamp = endTimestamp, successful = successful, exception = exception, + prevState = prevState, resState = resState, data = data, deviceLogs = deviceLogs, meta = meta) +} diff --git a/src/main/kotlin/org/droidmate/explorationModel/interaction/State.kt b/src/main/kotlin/org/droidmate/explorationModel/interaction/State.kt new file mode 100644 index 0000000..e0ebb6d --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/interaction/State.kt @@ -0,0 +1,143 @@ +// DroidMate, an automated execution generator for Android apps. +// Copyright (C) 2012-2018. Saarland University +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// Current Maintainers: +// Nataniel Borges Jr. +// Jenny Hotzkow +// +// Former Maintainers: +// Konrad Jamrozik +// +// web: www.droidmate.org + +package org.droidmate.explorationModel.interaction + +import org.droidmate.explorationModel.ConcreteId +import org.droidmate.explorationModel.config.ConfigProperties.ModelProperties.dump.sep +import org.droidmate.explorationModel.config.ModelConfig +import org.droidmate.explorationModel.emptyUUID +import org.droidmate.explorationModel.plus +import org.droidmate.explorationModel.retention.StringCreator +import java.io.File +import java.util.* + +/** + * States have two components, the Id determined by its Widgets image, text and description and the ConfigId defined by the WidgetsProperties. + ** be aware that the list of widgets is not guaranteed to be sorted in any specific order*/ +open class State (_widgets: Collection, val isHomeScreen: Boolean = false) { + + val stateId by lazy { + ConcreteId(uid, configId) + } + + val uid: UUID by lazy { lazyIds.value.uid } + val configId: UUID by lazy { lazyIds.value.configId } + + val hasActionableWidgets by lazy{ actionableWidgets.isNotEmpty() } + val hasEdit: Boolean by lazy { widgets.any { it.isInputField } } + val widgets by lazy { _widgets.sortedBy { it.id.toString() } } + + /**------------------------------- open function default implementations ------------------------------------------**/ + + open val actionableWidgets by lazy { widgets.filter { it.isInteractive } } + open val visibleTargets by lazy { actionableWidgets.filter { it.canInteractWith }} + + protected open val lazyIds: Lazy = + lazy { + widgets.fold(ConcreteId(emptyUUID, emptyUUID)) { (id, configId), widget -> + // e.g. keyboard elements are ignored for uid computation within [addRelevantId] + // however different selectable auto-completion proposes are only 'rendered' + // such that we have to include the img id (part of configId) to ensure different state configuration id's if these are different + ConcreteId(addRelevantId(id, widget), configId + widget.uid + widget.id.configId) + } + } + + + /** + * We ignore keyboard elements from the unique identifier, they will be only part of this states configuration. + * For elements without text nlpText only the image is available which may introduce variance just due to sleigh color differences, therefore + * non-text elements are only considered if they can be acted upon or are leaf elements + */ + protected open fun isRelevantForId(w: Widget): Boolean = ( !isHomeScreen && !w.isKeyboard + && (w.nlpText.isNotBlank() || w.isInteractive || w.isLeaf() ) + ) + + + @Suppress("SpellCheckingInspection") + open val isAppHasStoppedDialogBox: Boolean by lazy { + widgets.any { it.resourceId == "android:id/aerr_close" } && + widgets.any { it.resourceId == "android:id/aerr_wait" } + } + + open val isRequestRuntimePermissionDialogBox: Boolean by lazy { + widgets.any { // identify if we have a permission request + it.resourceId == resIdRuntimePermissionDialog || // maybe it is safer to check for packageName 'com.google.android.packageinstaller' only? + // handle cases for apps who 'customize' this request and use own resourceIds e.g. Home-Depot + when(it.text.toUpperCase()) { + "ALLOW", "DENY", "DON'T ALLOW" -> true + else -> false + } + } + // check that we have a ok or allow button + && widgets.any{it.text.toUpperCase().let{ wText -> wText == "ALLOW" || wText == "OK" } } + } + + + /** write CSV + * + * [uid] => stateId_[HS,{}] as file name (HS is only present if isHomeScreen is true) + */ + open fun dump(config: ModelConfig) { + File( config.widgetFile(stateId,isHomeScreen) ).bufferedWriter().use { all -> + all.write(StringCreator.widgetHeader(config[sep])) + + widgets.forEach { + all.newLine() + all.write( StringCreator.createPropertyString(it, config[sep]) ) + } + } + } + + /** used by our tests to verify correct widget-string representations */ + internal fun widgetsDump(sep: String) = widgets.map{ StringCreator.createPropertyString(it, sep) } + + companion object { + private const val resIdRuntimePermissionDialog = "com.android.packageinstaller:id/dialog_container" + + /** dummy element if a state has to be given but no widget data is available */ + @JvmStatic + val emptyState: State by lazy { State( emptyList() ) } + } + /** this function is used to add any widget.uid if it fulfills specific criteria + * (i.e. it can be acted upon, has text nlpText or it is a leaf) */ + private fun addRelevantId(id: UUID, w: Widget): UUID = if (isRelevantForId(w)){ id + w.uid } else id + + override fun equals(other: Any?): Boolean { + return when (other) { + is State -> uid == other.uid && configId == other.configId + else -> false + } + } + + override fun hashCode(): Int { + return uid.hashCode() + configId.hashCode() + } + + override fun toString(): String { + return "State[$stateId, widgets=${widgets.size}]" + } + +} diff --git a/src/main/kotlin/org/droidmate/explorationModel/interaction/Widget.kt b/src/main/kotlin/org/droidmate/explorationModel/interaction/Widget.kt new file mode 100644 index 0000000..8c246bb --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/interaction/Widget.kt @@ -0,0 +1,197 @@ +// DroidMate, an automated execution generator for Android apps. +// Copyright (C) 2012-2018. Saarland University +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// Current Maintainers: +// Nataniel Borges Jr. +// Jenny Hotzkow +// +// Former Maintainers: +// Konrad Jamrozik +// +// web: www.droidmate.org + +package org.droidmate.explorationModel.interaction + +import org.droidmate.deviceInterface.exploration.PType +import org.droidmate.deviceInterface.exploration.Persistent +import org.droidmate.deviceInterface.exploration.Rectangle +import org.droidmate.deviceInterface.exploration.UiElementPropertiesI +import org.droidmate.explorationModel.* +import org.droidmate.explorationModel.retention.StringCreator +import java.util.* +import kotlin.collections.HashMap + +open class Widget internal constructor(properties: UiElementPropertiesI, + val parentId: ConcreteId?): UiElementPropertiesI { + + override val metaInfo: List = properties.metaInfo + + /** @see computeUId */ + val uid: UUID get() = id.uid + + /** id to characterize the current 'configuration' of an element, e.g. is it definedAsVisible, checked etc + * @see computePropertyId */ + val configId: UUID get() = id.configId + + /** A widget mainly consists of two parts, [uid] encompasses the identifying one [image,Text,Description] used for unique identification + * and the modifiable properties, like checked, focused etc. identified via [configId] (and possibly [imgId]) + * @see computeConcreteId + */ + @property:Persistent("Unique Id",0, PType.ConcreteId) + val id by lazy { computeConcreteId() } + + /** This property determines if we could interact with this element, however it may be currently out of screen, + * such that we need to navigate to it firs. + * To know if we can directly interact with this widget right now check [canInteractWith]. + */ + val isInteractive: Boolean by lazy { computeInteractive() } + + /** True, if this widget is interactive and currently visible on the screen **/ + val canInteractWith: Boolean by lazy { isVisible && isInteractive } + + @Suppress("MemberVisibilityCanBePrivate") + val isVisible by lazy{ definedAsVisible && visibleBounds.isNotEmpty() } + + val hasParent get() = parentHash != 0 + + /**------------------------------- open function default implementations ------------------------------------------**/ + + open val nlpText: String by lazy { "$hintText $text $contentDesc".replace("\\s+", " ").splitOnCaseSwitch().trim() } + + open fun isLeaf(): Boolean = childHashes.isEmpty() + + protected open fun computeInteractive(): Boolean = + enabled && ( isInputField || clickable || checked ?: false || longClickable || scrollable) + + /** + * @see computeUId + * @see computePropertyId + */ + protected open fun computeConcreteId(): ConcreteId = ConcreteId(computeUId(), computePropertyId()) + + /** compute the widget.uid based on its visible natural language content/resourceId if it exists, or based on [uidString] otherwise */ + protected open fun computeUId():UUID = when { + !isKeyboard && isInputField -> when { // special care for EditText elements, as the input text will change the [text] property + hintText.isNotBlank() -> hintText.toUUID() + contentDesc.isNotBlank() -> contentDesc.toUUID() + resourceId.isNotBlank() -> resourceId.toUUID() + else -> uidString.toUUID() + } + !isKeyboard && nlpText.isNotBlank() -> { // compute id from textual nlpText if there is any + val ignoreNumbers = nlpText.replace("[0-9]", "") + if (ignoreNumbers.isNotEmpty()) ignoreNumbers.toUUID() + else nlpText.toUUID() + } + else -> // we have an Widget without any visible text + if(resourceId.isNotBlank()) resourceId.toUUID() + else uidString.toUUID() + } + // used if we have no NLP text available + private val uidString by lazy{ listOf(className, packageName, isPassword, isKeyboard, idHash).joinToString(separator = "<;>") } + + /** compute the configuration of this Widget by a subset of its properties. + */ + protected open fun computePropertyId(): UUID { + val relevantProperties = listOf(enabled, definedAsVisible, visibleBounds, text, checked, focused, selected, clickable, longClickable, + scrollable, isInputField, imgId, xpath, idHash, childHashes) // REMARK we need the xpath/idHash here because this information is not encoded in the uid IF we have some text or resourceId, but on state parsing we need the correct idHash/parentHash to reconstruct the Widget object, this is currently expressed via config id but could be handled by changing the widget-parser queue as well + return relevantProperties.joinToString("<;>").toUUID() + } + + + companion object { + /** used for dummy initializations, if nullable is undesirable */ + val emptyWidget by lazy{ Widget(DummyProperties,null) } + + /**------------------------------------------ private methods ---------------------------------------------------**/ + private fun String.splitOnCaseSwitch(): String{ + if(this.isBlank()) return "" + var newString = "" + this.forEachIndexed { i, c -> + newString += when{ + !c.isLetter() -> " " + c.isUpperCase() && i>0 && this[i-1].isLowerCase() -> " $c" + else -> c + } + } + return newString + } + } + + /*** overwritten functions ***/ + override fun equals(other: Any?): Boolean { + return when (other) { + is Widget -> id == other.id + else -> false + } + } + + override fun hashCode(): Int { + return id.hashCode() + } + + private fun String.ifNotEmpty(label:String) = if(isNotBlank()) "$label=$this" else "" + + private val simpleClassName by lazy { className.substring(className.lastIndexOf(".") + 1) } + override fun toString(): String { + return "interactive=$isInteractive-${uid}_$configId: $simpleClassName" + + "[${text.ifNotEmpty("text")} ${hintText.ifNotEmpty("hint")} ${contentDesc.ifNotEmpty("description")} " + + "${resourceId.ifNotEmpty("resId")}, inputType=$inputType $visibleBounds]" + } + + /**----------------------------------- final properties from ui extraction -----------------------------------------*/ + + final override val imgId: Int = properties.imgId + final override val visibleBounds: org.droidmate.deviceInterface.exploration.Rectangle = properties.visibleBounds + final override val isKeyboard: Boolean = properties.isKeyboard + final override val inputType: Int = properties.inputType + final override val text: String = properties.text + final override val hintText: String = properties.hintText + final override val contentDesc: String = properties.contentDesc + final override val checked: DeactivatableFlag = properties.checked + final override val resourceId: String = properties.resourceId + final override val className: String = properties.className + final override val packageName: String = properties.packageName + final override val enabled: Boolean = properties.enabled + final override val isInputField: Boolean = properties.isInputField + final override val isPassword: Boolean = properties.isPassword + final override val clickable: Boolean = properties.clickable + final override val longClickable: Boolean = properties.longClickable + final override val scrollable: Boolean = properties.scrollable + final override val focused: DeactivatableFlag = properties.focused + final override val selected: Boolean = properties.selected + final override val boundaries = properties.boundaries + final override val visibleAreas: List = properties.visibleAreas + final override val xpath: String = properties.xpath + final override val idHash: Int = properties.idHash + final override val parentHash: Int = properties.parentHash + final override val childHashes: List = properties.childHashes + final override val definedAsVisible: Boolean = properties.definedAsVisible + final override val hasUncoveredArea: Boolean = properties.hasUncoveredArea + + @JvmOverloads open fun copy(boundaries: Rectangle = this.boundaries, visibleBounds:Rectangle = this.visibleBounds, + defVisible:Boolean=this.definedAsVisible): Widget{ + val properties: MutableMap = HashMap() + StringCreator.annotatedProperties.forEach { p -> + properties[p.property.name] = p.property.call(this) + } + properties[this::boundaries.name] = boundaries + properties[this::visibleBounds.name] = visibleBounds + properties[this::definedAsVisible.name] = defVisible + return Widget(UiElementP(properties),parentId) + } + /* end override */ + +} diff --git a/src/main/kotlin/org/droidmate/explorationModel/retention/StringCreator.kt b/src/main/kotlin/org/droidmate/explorationModel/retention/StringCreator.kt new file mode 100644 index 0000000..d41e4d7 --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/retention/StringCreator.kt @@ -0,0 +1,194 @@ +package org.droidmate.explorationModel.retention + +import org.droidmate.deviceInterface.exploration.PType +import org.droidmate.deviceInterface.exploration.Persistent +import org.droidmate.deviceInterface.exploration.Rectangle +import org.droidmate.deviceInterface.exploration.UiElementPropertiesI +import org.droidmate.explorationModel.* +import org.droidmate.explorationModel.config.ModelConfig +import org.droidmate.explorationModel.interaction.Interaction +import org.droidmate.explorationModel.interaction.Widget +import java.time.LocalDateTime +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.findAnnotation + +var debugParsing = false + +typealias WidgetProperty = AnnotatedProperty + +typealias PropertyValue = Pair +fun PropertyValue.getPropertyName() = this.first +fun PropertyValue.getValue() = this.second + +data class AnnotatedProperty(val property: KProperty1, val annotation: Persistent){ + private fun String.getListElements(): List = substring(1,length-1)// remove the list brackets '[ .. ]' + .split(",").filter { it.trim().isNotBlank() } // split into separate list elements + + private fun String.parseRectangle() = this.split(Rectangle.toStringSeparator).map { it.trim() }.let{ params -> + check(params.size==4) + Rectangle(params[0].toInt(),params[1].toInt(),params[2].toInt(),params[3].toInt()) + } + + @Suppress("IMPLICIT_CAST_TO_ANY") + fun parseValue(values: List, indexMap: Map,Int>): PropertyValue { + val s = indexMap[this]?.let{ values[it].trim() } + debugOut("parse $s of type ${annotation.type}", false) + return property.name to when (annotation.type) { + PType.Int -> s?.toInt() ?: 0 + PType.DeactivatableFlag -> if (s == "disabled") null else s?.toBoolean() ?: false + PType.Boolean -> s?.toBoolean() ?: false + PType.Rectangle -> s?.parseRectangle() ?: Rectangle.empty() + PType.RectangleList -> s?.getListElements()?.map { it.parseRectangle() } ?: emptyList() // create the list of rectangles + PType.String -> s?: "NOT PERSISTED" + PType.IntList -> s?.getListElements()?.map { it.trim().toInt() } ?: emptyList() + PType.ConcreteId -> if(s == null) emptyId else ConcreteId.fromString(s) + PType.DateTime -> LocalDateTime.parse(s) + } + } + + override fun toString(): String { + return "${annotation.header}: ${annotation.type}" + } +} + +object StringCreator { + internal fun createPropertyString(t: PType, pv: Any?):String = + when{ + t == PType.DeactivatableFlag -> pv?.toString() ?: "disabled" + t == PType.ConcreteId && pv is Widget? -> pv?.id.toString() // necessary for [Interaction.targetWidget] + else -> pv.toString() + } + + private inline fun Sequence>.processProperty(o: T, crossinline body:(Sequence, String>>)->R): R = + body(this.map { p:AnnotatedProperty -> + // val annotation: Persistent = annotatedProperty.annotations.find { it is Persistent } as Persistent + Pair(p,StringCreator.createPropertyString(p.annotation.type,p.property.call(o))) // determine the actual values to be stored and transform them into string format + .also{ (p,s) -> + if(debugParsing) { + val v = p.property.call(o) + val parsed = p.parseValue(listOf(s), mapOf(p to 0)).getValue() + val validString = + if (p.property.name == Interaction::targetWidget.name) ((v as? Widget)?.id == parsed) + else v == parsed + assert(validString) { "ERROR generated string cannot be parsed to the correct value ${p.property.name}: has value $v but parsed value is $parsed" } + } + } + }) + + fun createPropertyString(w: Widget,sep: String): String = + annotatedProperties.processProperty(w){ + it.joinToString(sep) { (_,valueString) -> valueString } + } + fun createActionString(a: Interaction, sep: String): String = + actionProperties.processProperty(a){ + it.joinToString(sep) { (_,valueString) -> valueString } + } + + + /** [indexMap] has to contain the correct index in the string [values] list for each property */ + internal inline fun Sequence>.parsePropertyString(values: List, indexMap: Map,Int>): Map = + this//.filter { indexMap.containsKey(it) } // we allow for default values for missing properties + .map{ it.parseValue(values, indexMap) }.toMap() + + + internal fun parseWidgetPropertyString(values: List, indexMap: Map): UiElementP + = UiElementP( baseAnnotations.parsePropertyString(values,indexMap) ) + + internal fun parseActionPropertyString(values: List, target: Widget?, + indexMap: Map, Int> = defaultActionMap): Interaction + = with(actionProperties.parsePropertyString(values,indexMap)){ + Interaction( targetWidget = target, + actionType = get(Interaction::actionType.name) as String, + startTimestamp = get(Interaction::startTimestamp.name) as LocalDateTime, + endTimestamp = get(Interaction::endTimestamp.name) as LocalDateTime, + successful = get(Interaction::successful.name) as Boolean, + exception = get(Interaction::exception.name) as String, + prevState = get(Interaction::prevState.name) as ConcreteId, + resState = get(Interaction::resState.name) as ConcreteId, + data = get(Interaction::data.name) as String + )} + + internal val baseAnnotations: Sequence by lazy { + UiElementPropertiesI::class.declaredMemberProperties.mapNotNull { property -> + property.findAnnotation()?.let { annotation -> AnnotatedProperty(property, annotation) } + }.asSequence() + } + + @JvmStatic + val actionProperties: Sequence> by lazy{ + Interaction::class.declaredMemberProperties.mapNotNull { property -> + property.findAnnotation()?.let{ annotation -> AnnotatedProperty(property,annotation) } + }.sortedBy { (_,annotation) -> annotation.ordinal }.asSequence() + } + + @JvmStatic + val widgetProperties: Sequence by lazy { + Widget::class.declaredMemberProperties.mapNotNull { property -> + property.findAnnotation()?.let{ annotation -> WidgetProperty(property,annotation) } + }.asSequence() + } + + @JvmStatic + val annotatedProperties: Sequence by lazy { + widgetProperties.plus( baseAnnotations ).sortedBy { (_,annotation) -> annotation.ordinal }.asSequence() + } + + fun headerFor(p: KProperty1): String? = p.findAnnotation()?.header + + @JvmStatic + val widgetHeader: (String)->String = { sep -> annotatedProperties.joinToString(sep) { it.annotation.header }} + + @JvmStatic + val actionHeader: (String)->String = { sep -> actionProperties.joinToString(sep) { it.annotation.header }} + + @JvmStatic + val defaultMap: Map = annotatedProperties.mapIndexed{ i, p -> Pair(p,i)}.toMap() + + @JvmStatic + val defaultActionMap = actionProperties.mapIndexed{ i, p -> Pair(p,i)}.toMap() + + @JvmStatic fun main(args: Array) { + val sep = ";\t" + val s = createPropertyString(Widget.emptyWidget,sep) + println(s) + println("-------- create value map") + val vMap: Map = widgetHeader(sep).split(sep).associate { h -> +// println("find $h") + val i = annotatedProperties.indexOfFirst { it.annotation.header.trim() == h.trim() } + Pair(annotatedProperties.elementAt(i),i) + } + + val verifyProperties = vMap.filter { widgetProperties.contains(it.key) } + println("-- Widget properties, currently only used for verify \n " + + "${verifyProperties.map { "'${it.key.annotation.header}': Pair " + + "= ${it.key.parseValue(s.split(sep),verifyProperties)}" }}") + + println("-------- create widget property") + val wp = parseWidgetPropertyString(s.split(sep),vMap) + println(wp) + val w = Model.emptyModel(ModelConfig("someApp")).generateWidgets(mapOf(wp.idHash to wp)) + println(createPropertyString(w.first(),sep)) + } + +} + +// possibly used later for debug strings -> keep it for now + +// fun getStrippedResourceId(): String = resourceId.removePrefix("$packageName:") +// fun toShortString(): String { +// return "Wdgt:$simpleClassName/\"$text\"/\"$uid\"/[${bounds.centerX.toInt()},${bounds.centerY.toInt()}]" +// } +// +// fun toTabulatedString(includeClassName: Boolean = true): String { +// val pCls = simpleClassName.padEnd(20, ' ') +// val pResId = resourceId.padEnd(64, ' ') +// val pText = text.padEnd(40, ' ') +// val pContDesc = contentDesc.padEnd(40, ' ') +// val px = "${bounds.centerX.toInt()}".padStart(4, ' ') +// val py = "${bounds.centerY.toInt()}".padStart(4, ' ') +// +// val clsPart = if (includeClassName) "Wdgt: $pCls / " else "" +// +// return "${clsPart}resourceId: $pResId / text: $pText / contDesc: $pContDesc / click xy: [$px,$py]" +// } diff --git a/src/main/kotlin/org/droidmate/explorationModel/retention/loading/ContentReader.kt b/src/main/kotlin/org/droidmate/explorationModel/retention/loading/ContentReader.kt new file mode 100644 index 0000000..bf0a70b --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/retention/loading/ContentReader.kt @@ -0,0 +1,56 @@ +package org.droidmate.explorationModel.retention.loading + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import org.droidmate.explorationModel.ConcreteId +import org.droidmate.explorationModel.config.ModelConfig +import org.droidmate.explorationModel.config.ConfigProperties +import org.droidmate.explorationModel.emptyId +import java.io.BufferedReader +import java.io.FileReader +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.coroutines.coroutineContext +import kotlin.streams.toList + + +open class ContentReader(val config: ModelConfig){ + @Suppress("UNUSED_PARAMETER") + fun log(msg: String) + {} +// = println("[${Thread.currentThread().name}] $msg") + + open fun getFileContent(path: Path, skip: Long): List? = path.toFile().let { file -> // TODO this and P_processLines would be moved to Processor common function + log("\n getFileContent skip=$skip, path= ${path.toUri()} \n") + + if (!file.exists()) { return null } // otherwise this state has no widgets + + return BufferedReader(FileReader(file)).use { + it.lines().skip(skip).toList() + } + } + + open fun getStateFile(stateId: ConcreteId): Pair{ + val contentPath = Files.list(Paths.get(config.stateDst.toUri())).use { it.toList() }.first { + it.fileName.toString().startsWith( stateId.toString()) } + return Pair(contentPath, contentPath.fileName.toString().contains("HS")//, it.substring(it.indexOf("_PN")+4,it.indexOf(config[ConfigProperties.ModelProperties.dump.stateFileExtension])) + ) + } + + fun getHeader(path: Path): List{ + return getFileContent(path,0)?.first()?.split(config[ConfigProperties.ModelProperties.dump.sep])!! + } + suspend inline fun processLines(path: Path, skip: Long = 1, crossinline lineProcessor: suspend (List,CoroutineScope) -> T): List { + log("call P_processLines for ${path.toUri()}") + getFileContent(path,skip)?.let { br -> // skip the first line (headline) + assert(br.count() > 0 // all 'non-empty' states have to have entries for their widgets + || skip==0L || !path.fileName.startsWith(emptyId.toString())) + { "ERROR on model loading: file ${path.fileName} does not contain any entries" } + val scope = CoroutineScope(coroutineContext+ Job()) + return br.map { line -> + lineProcessor(line.split(config[ConfigProperties.ModelProperties.dump.sep]).map { it.trim() },scope) } + } ?: return emptyList() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/org/droidmate/explorationModel/retention/loading/ModelParser.kt b/src/main/kotlin/org/droidmate/explorationModel/retention/loading/ModelParser.kt new file mode 100644 index 0000000..08e78b6 --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/retention/loading/ModelParser.kt @@ -0,0 +1,301 @@ +package org.droidmate.explorationModel.retention.loading + +import com.natpryce.konfig.CommandLineOption +import com.natpryce.konfig.getValue +import com.natpryce.konfig.parseArgs +import com.natpryce.konfig.stringType +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.channels.produce +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.droidmate.deviceInterface.exploration.UiElementPropertiesI +import org.droidmate.deviceInterface.exploration.isClick +import org.droidmate.deviceInterface.exploration.isLongClick +import org.droidmate.deviceInterface.exploration.isTextInsert +import org.droidmate.explorationModel.* +import org.droidmate.explorationModel.ConcreteId.Companion.fromString +import org.droidmate.explorationModel.config.* +import org.droidmate.explorationModel.interaction.Interaction +import org.droidmate.explorationModel.interaction.State +import org.droidmate.explorationModel.interaction.Widget +import org.droidmate.explorationModel.retention.StringCreator +import org.droidmate.explorationModel.retention.StringCreator.headerFor +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.* +import kotlin.coroutines.coroutineContext + +/** public interface, used to parse any model **/ +object ModelParser{ + @JvmOverloads suspend fun loadModel(config: ModelConfig, watcher: LinkedList = LinkedList(), + autoFix: Boolean = false, sequential: Boolean = false, enablePrint: Boolean = false, + contentReader: ContentReader = ContentReader(config), enableChecks: Boolean = true, + customHeaderMap: Map = emptyMap()) + : Model{ + if(sequential) return debugT("model loading (sequential)", { + ModelParserS(config, compatibilityMode = autoFix, enablePrint = enablePrint, reader = contentReader, enableChecks = enableChecks).loadModel(watcher, customHeaderMap) + }, inMillis = true) + return debugT("model loading (parallel)", { + ModelParserP(config, compatibilityMode = autoFix, enablePrint = enablePrint, reader = contentReader, enableChecks = enableChecks).loadModel(watcher, customHeaderMap) + }, inMillis = true) + } +} + +internal abstract class ModelParserI: ParserI>{ +// override val enableDebug get() = true + + abstract val config: ModelConfig + abstract val reader: ContentReader + abstract val stateParser: StateParserI + abstract val widgetParser: WidgetParserI + abstract val enablePrint: Boolean + abstract val isSequential: Boolean + + override val logger: Logger = LoggerFactory.getLogger(javaClass) + + override val model by lazy{ Model.emptyModel(config) } + protected val actionParseJobName: (List)->String = { actionS -> + "actionParser ${actionS[Interaction.srcStateIdx]}->${actionS[Interaction.resStateIdx]}"} + + // watcher state restoration for ModelFeatureI should be automatically handled via trace.updateAll (these are independent from the explorationContext) + suspend fun loadModel(watcher: LinkedList = LinkedList(), customHeaderMap: Map = emptyMap()): Model = withContext(CoroutineName("ModelParsing ${config.appName}(${config.baseDir})")+Job()){ + coroutineScope { // this will wait for all coroutines launched in this scope + stateParser.headerRenaming = customHeaderMap + // the very first state of any trace is always an empty state which is automatically added on Model initialization + addEmptyState() + // start producer who just sends trace paths to the multiple trace processor jobs + val producer = traceProducer() + repeat(if(isSequential) 1 else 5) + { launch { traceProcessor( producer, watcher )} } // process up to 5 exploration traces in parallel + + } + clearQueues() + return@withContext model + } + private fun clearQueues() { + stateParser.queue.clear() + widgetParser.queue.clear() + } + abstract suspend fun addEmptyState() + + protected open suspend fun traceProducer() = coroutineScope { + produce(context = CoroutineName("trace Producer"), capacity = 5) { + logger.trace("PRODUCER CALL") + Files.list(Paths.get(config.baseDir.toUri())).use { s -> + s.filter { it.fileName.toString().startsWith(config[ConfigProperties.ModelProperties.dump.traceFilePrefix]) } + .also { + for (p in it) { + send(p) + } + } + } + }} + + private val modelMutex = Mutex() + + private suspend fun traceProcessor(channel: ReceiveChannel, watcher: LinkedList): Unit = coroutineScope { + logger.trace("trace processor launched") + if(enablePrint) logger.info("trace processor launched") + channel.consumeEach { tracePath -> + if(enablePrint) logger.info("\nprocess TracePath $tracePath") + val traceId = + try { + UUID.fromString(tracePath.fileName.toString() + .removePrefix(config[ConfigProperties.ModelProperties.dump.traceFilePrefix]) + .removeSuffix(config[ConfigProperties.ModelProperties.dump.traceFileExtension])) + }catch(e:IllegalArgumentException){ // tests do not use valid UUIDs but rather int indices + emptyUUID + } + modelMutex.withLock { model.initNewTrace(watcher, traceId) } + .let { trace -> + val actionPairs = reader.processLines(tracePath, lineProcessor = processor) + // use maximal parallelism to process the single actions/states + if (watcher.isEmpty()){ + val resState = getElem(actionPairs.last()).second + logger.debug(" wait for completion of actions") + trace.updateAll(actionPairs.map { getElem(it).first }, resState) + } // update trace actions + else { + logger.debug(" wait for completion of EACH action") + actionPairs.forEach { getElem(it).let{ (action,resState) -> trace.update(action, resState) }} + } + } + logger.debug("CONSUMED trace $tracePath") + } + } + + /** parse a single action this function is called in the processor either asynchronous (Deferred) or sequential (blocking) */ + suspend fun parseAction(actionS: List, scope: CoroutineScope): Pair { + if(enablePrint) println("\n\t ---> parse action $actionS") + val resId = ConcreteId.fromString(actionS[Interaction.resStateIdx])!! + val resState = stateParser.queue.computeIfAbsent(resId, stateParser.parseIfAbsent(coroutineContext)).getState() + val targetWidgetId = widgetParser.fixedWidgetId(actionS[Interaction.widgetIdx]) + + val srcId = fromString(actionS[Interaction.srcStateIdx])!! + val srcState = stateParser.queue.computeIfAbsent(srcId,stateParser.parseIfAbsent(coroutineContext)).getState() + verify("ERROR could not find target widget $targetWidgetId in source state $srcId", { + + log("wait for srcState $srcId") + targetWidgetId == null || srcState.widgets.any { it.id == targetWidgetId } + }){ // repair function + val actionType = actionS[Interaction.actionTypeIdx] + var srcS = stateParser.queue[srcId] + while(srcS == null) { // due to concurrency the value is not yet written to queue -> wait a bit + delay(5) + srcS = stateParser.queue[srcId] + } + val possibleTargets = srcState.widgets.filter { + targetWidgetId!!.uid == it.uid && it.isInteractive && rightActionType(it,actionType)} + when(possibleTargets.size){ + 0 -> throw IllegalStateException("cannot re-compute targetWidget $targetWidgetId in state $srcId") + 1 -> widgetParser.addFixedWidgetId(targetWidgetId!!, possibleTargets.first().id) + else -> { + println("WARN there are multiple options for the interacted target widget we just chose the first one") + widgetParser.addFixedWidgetId(targetWidgetId!!, possibleTargets.first().id) + } + } + } + val targetWidget = widgetParser.fixedWidgetId(targetWidgetId)?.let { tId -> + srcState.widgets.find { it.id == tId } + } + val fixedActionS = mutableListOf().apply { addAll(actionS) } + fixedActionS[Interaction.resStateIdx] = resState.stateId.toString() + fixedActionS[Interaction.srcStateIdx] = srcState.stateId.toString() //do NOT use local val srcId as that may be the old id + + if(actionS!=fixedActionS) + println("id's changed due to automatic repair new action is \n $fixedActionS\n instead of \n $actionS") + + return Pair(StringCreator.parseActionPropertyString(fixedActionS, targetWidget), resState) + .also { (action,_) -> + log("\n computed TRACE ${actionS[Interaction.resStateIdx]}: $action") + } + } + private val rightActionType: (Widget, actionType: String)->Boolean = { w, t -> + w.enabled && when{ + t.isClick() -> w.clickable || w.checked != null + t.isLongClick() -> w.longClickable + t.isTextInsert() -> w.isInputField + else -> false + } + } + + + @Suppress("ReplaceSingleLineLet") + suspend fun S.getState() = this.let{ e -> stateParser.getElem(e) } + + companion object { + + /** + * helping/debug function to manually load a model. + * The directory containing the 'model' folder and the app name have to be specified, e.g. + * '--Output-outputDir=pathToModelDir --appName=sampleApp' + * --Core-debugMode=true (optional for enabling print-outs) + */ + @JvmStatic fun main(args: Array) { + // stateDiff(args) +// runBlocking { + val appName by stringType + val cfg = parseArgs(args, + CommandLineOption(ConfigProperties.Output.outputDir), + CommandLineOption(appName) + ).first + val config = ModelConfig(cfg[appName], true, cfg = cfg) + //REMARK old models are most likely not compatible, since the idHash may not be unique (probably some bug on UI extraction) + @Suppress("UNUSED_VARIABLE") + val headerMap = mapOf( + "HashId" to headerFor(UiElementPropertiesI::idHash)!!, + "widget class" to headerFor(UiElementPropertiesI::className)!!, + "Text" to headerFor(UiElementPropertiesI::text)!!, + "Description" to headerFor(UiElementPropertiesI::contentDesc)!!, + "Enabled" to headerFor(UiElementPropertiesI::enabled)!!, + "Visible" to headerFor(UiElementPropertiesI::definedAsVisible)!!, + "Clickable" to headerFor(UiElementPropertiesI::clickable)!!, + "LongClickable" to headerFor(UiElementPropertiesI::longClickable)!!, + "Scrollable" to headerFor(UiElementPropertiesI::scrollable)!!, + "Checked" to headerFor(UiElementPropertiesI::checked)!!, + "Editable" to headerFor(UiElementPropertiesI::isInputField)!!, + "Focused" to headerFor(UiElementPropertiesI::focused)!!, + "IsPassword" to headerFor(UiElementPropertiesI::isPassword)!!, + "XPath" to headerFor(UiElementPropertiesI::xpath)!!, + "PackageName" to headerFor(UiElementPropertiesI::packageName)!! + //MISSING translation of BoundsX,..Y,..Width,..Height to visibleBoundaries + //MISSING instead of parentHash we had parentID persisted + ) + + val m = + // loadModel(config, autoFix = false, sequential = true) + runBlocking { ModelParser.loadModel(config, autoFix = false, sequential = true, enablePrint = false//, customHeaderMap = headerMap + )}.also { println(it) } + println("performance test") + var ts =0L + var tp =0L + runBlocking { + repeat(10) { + debugT("load time sequential", { ModelParserS(config).loadModel() }, + timer = { time -> ts += time / 1000000 }, + inMillis = true).also { time -> println(time) } + debugT("load time parallel", { ModelParserP(config).loadModel() }, + timer = { time -> tp += time / 1000000 }, + inMillis = true).also { time -> println(time) } + } + } + println(" overall time \nsequential = $ts avg=${ts/10000.0} \nparallel = $tp avg=${tp/10000.0}") + /** dump the (repaired) model */ /* + runBlocking { + m.dumpModel(ModelConfig("repaired-${config.appName}", cfg = cfg)) + m.modelDumpJob.joinChildren() + } + // */ + println("model load finished: ${config.appName} $m") + } +// } + } /** end COMPANION **/ + +} + +internal open class ModelParserP(override val config: ModelConfig, override val reader: ContentReader = ContentReader(config), + override val compatibilityMode: Boolean = false, override val enablePrint: Boolean = false, + override val enableChecks: Boolean = true) + : ModelParserI>, Deferred, Deferred>() { + override val isSequential: Boolean = false + + override val widgetParser by lazy { WidgetParserP(model, compatibilityMode, enableChecks) } + override val stateParser by lazy { StateParserP(widgetParser, reader, model, compatibilityMode, enableChecks) } + + override val processor: suspend (s: List, CoroutineScope) -> Deferred> = { actionS, scope -> + CoroutineScope(coroutineContext+Job()).async(CoroutineName(actionParseJobName(actionS))) { parseAction(actionS, scope) } + } + + override suspend fun addEmptyState() { + State.emptyState.let{ stateParser.queue[it.stateId] = CoroutineScope(coroutineContext).async(CoroutineName("empty State")) { it } } + } + + override suspend fun getElem(e: Deferred>): Pair = e.await() + +} + +private class ModelParserS(override val config: ModelConfig, override val reader: ContentReader = ContentReader(config), + override val compatibilityMode: Boolean = false, override val enablePrint: Boolean = false, + override val enableChecks: Boolean = true) + : ModelParserI, State, UiElementPropertiesI>() { + override val isSequential: Boolean = true + + override val widgetParser by lazy { WidgetParserS(model, compatibilityMode, enableChecks) } + override val stateParser by lazy { StateParserS(widgetParser, reader, model, compatibilityMode, enableChecks) } + + override val processor: suspend (s: List, CoroutineScope) -> Pair = { actionS:List, scope -> + parseAction(actionS, scope) + } + + override suspend fun addEmptyState() { + State.emptyState.let{ stateParser.queue[it.stateId] = it } + } + + override suspend fun getElem(e: Pair): Pair = e + +} \ No newline at end of file diff --git a/src/main/kotlin/org/droidmate/explorationModel/retention/loading/ParserI.kt b/src/main/kotlin/org/droidmate/explorationModel/retention/loading/ParserI.kt new file mode 100644 index 0000000..11414a2 --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/retention/loading/ParserI.kt @@ -0,0 +1,42 @@ +package org.droidmate.explorationModel.retention.loading + +import kotlinx.coroutines.CoroutineScope +import org.droidmate.explorationModel.Model +import org.droidmate.explorationModel.debugOut +import org.slf4j.Logger + +internal interface ParserI{ + val model: Model + + val processor: suspend (s: List, scope: CoroutineScope) -> T + suspend fun getElem(e: T): R + + val logger: Logger + val enableDebug get() = false + fun log(msg: String) + = debugOut("[${Thread.currentThread().name}] $msg", enableDebug) + + val compatibilityMode: Boolean + val enableChecks: Boolean + /** assert that a condition [c] is fulfilled or apply the [repair] function if compatibilityMode is enabled + * if neither [c] is fulfilled nor compatibilityMode is enabled we throw an assertion error with message [msg] + */ + suspend fun verify(msg:String,c: suspend ()->Boolean,repair: suspend ()->Unit){ + if(!enableChecks) return + if(!compatibilityMode) { + if (!c()) + throw IllegalStateException("invalid Model(enable compatibility mode to attempt transformation to valid state):\n$msg") + } else if(!c()){ + logger.warn("had to apply repair function due to parse error '$msg' in thread [${Thread.currentThread().name}]") + repair() + } + } + + /** + * verify that condition [c] is fulfilled and throw an [IllegalStateException] otherwise. + * If the model could be automatically repared please use the alternative verify method and provide a repair function + */ + suspend fun verify(msg:String,c: suspend ()->Boolean){ + verify(msg,c) {} + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/droidmate/explorationModel/retention/loading/StateParser.kt b/src/main/kotlin/org/droidmate/explorationModel/retention/loading/StateParser.kt new file mode 100644 index 0000000..46e6e33 --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/retention/loading/StateParser.kt @@ -0,0 +1,125 @@ +package org.droidmate.explorationModel.retention.loading + +import kotlinx.coroutines.* +import org.droidmate.deviceInterface.exploration.UiElementPropertiesI +import org.droidmate.explorationModel.ConcreteId +import org.droidmate.explorationModel.Model +import org.droidmate.explorationModel.debugOut +import org.droidmate.explorationModel.interaction.State +import org.droidmate.explorationModel.retention.loading.WidgetParserI.Companion.computeWidgetIndices +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import kotlin.collections.List +import kotlin.collections.Map +import kotlin.collections.MutableMap +import kotlin.collections.associate +import kotlin.collections.emptyMap +import kotlin.collections.find +import kotlin.collections.forEach +import kotlin.collections.get +import kotlin.collections.groupBy +import kotlin.collections.isNotEmpty +import kotlin.collections.map +import kotlin.collections.set +import kotlin.coroutines.CoroutineContext + +internal abstract class StateParserI: ParserI{ + var headerRenaming: Map = emptyMap() + abstract val widgetParser: WidgetParserI + abstract val reader: ContentReader + + override val logger: Logger = LoggerFactory.getLogger(javaClass) + + /** temporary map of all processed widgets for state parsing */ + abstract val queue: MutableMap + /** + * when compatibility mode is enabled this list will contain the mapping oldId->newlyComputedId + * to transform the model to the current (newer) id computation. + * This mapping is supposed to be used to adapt the action targets in the trace parser (Interaction entries) + */ + private val idMapping: ConcurrentHashMap = ConcurrentHashMap() +// override fun logcat(msg: String) { } + + /** parse the state either asynchronous (Deferred) or sequential (blocking) */ + @Suppress("FunctionName") + abstract fun P_S_process(id: ConcreteId, coroutineContext: CoroutineContext): T + + override val processor: suspend (s: List, scope: CoroutineScope) -> T = { _,_ -> TODO("not necessary anymore") } + + internal val parseIfAbsent: (CoroutineContext) -> (ConcreteId)->T = { context ->{ id -> + log("parse absent state $id") + P_S_process(id,context) + }} + + protected suspend fun computeState(stateId: ConcreteId): State { + log("\ncompute state $stateId") + val(contentPath,isHomeScreen) = reader.getStateFile(stateId) + if(!widgetParser.indicesComputed.get()) { + widgetParser.setCustomWidgetIndices( computeWidgetIndices(reader.getHeader(contentPath), headerRenaming) ) + widgetParser.indicesComputed.set(true) + } + val uiProperties = reader.processLines(path = contentPath, lineProcessor = widgetParser.processor) + .map { (id,e) -> id to widgetParser.getElem(id to e) } + uiProperties.groupBy { it.second.idHash }.forEach { + if(it.value.size>1){ + //FIXME that may happen for old models and will destroy the parent/child mapping, so for 'old' models we would have to parse the parentId instead + logger.error("ambiguous idHash elements found, this will result in model inconsistencies (${it.value})") + } + } + debugOut("${uiProperties.map { it.first.toString()+": HashId = ${it.second.idHash}" }}",false) + val widgets = model.generateWidgets(uiProperties.associate { (_,e) -> e.idHash to e }).also { + it.forEach { w -> uiProperties.find { p -> p.second.idHash == w.idHash }!!.let{ (id,_) -> + verify("ERROR on widget parsing inconsistent ID created ${w.id} instead of $id",{id == w.id}) { + idMapping[id] = w.id + } + }} + } + model.addWidgets(widgets) + + return if (widgets.isNotEmpty()) { + model.parseState(widgets, isHomeScreen).also { newState -> + + verify("ERROR different set of widgets used for UID computation used", { + stateId == newState.stateId + }) { + idMapping[stateId] = newState.stateId + } + model.addState(newState) + } + } else State.emptyState + } + + fun fixedStateId(idString: String) = ConcreteId.fromString(idString).let{ idMapping[it] ?: it } + +} + +internal class StateParserS(override val widgetParser: WidgetParserS, + override val reader: ContentReader, + override val model: Model, + override val compatibilityMode: Boolean, + override val enableChecks: Boolean) : StateParserI(){ + override val queue: MutableMap = HashMap() + + override fun P_S_process(id: ConcreteId, coroutineContext: CoroutineContext): State = runBlocking { computeState(id) } + + override suspend fun getElem(e: State): State = e +} + +internal class StateParserP(override val widgetParser: WidgetParserP, + override val reader: ContentReader, + override val model: Model, + override val compatibilityMode: Boolean, + override val enableChecks: Boolean) + : StateParserI, Deferred>(){ + override val queue: MutableMap> = ConcurrentHashMap() + + override fun P_S_process(id: ConcreteId, coroutineContext: CoroutineContext): Deferred = CoroutineScope(coroutineContext+Job()).async(CoroutineName("parseWidget $id")){ + log("parallel compute state $id") + computeState(id) + } + + override suspend fun getElem(e: Deferred): State = + e.await() +} \ No newline at end of file diff --git a/src/main/kotlin/org/droidmate/explorationModel/retention/loading/WidgetParser.kt b/src/main/kotlin/org/droidmate/explorationModel/retention/loading/WidgetParser.kt new file mode 100644 index 0000000..5cafc1d --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/retention/loading/WidgetParser.kt @@ -0,0 +1,115 @@ +package org.droidmate.explorationModel.retention.loading + +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.droidmate.deviceInterface.exploration.UiElementPropertiesI +import org.droidmate.explorationModel.* +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import org.droidmate.explorationModel.interaction.Widget +import org.droidmate.explorationModel.retention.StringCreator +import org.droidmate.explorationModel.retention.StringCreator.annotatedProperties +import org.droidmate.explorationModel.retention.WidgetProperty +import org.droidmate.explorationModel.retention.getValue +import kotlin.collections.HashMap + +internal abstract class WidgetParserI: ParserI, UiElementPropertiesI> { + var indicesComputed: AtomicBoolean = AtomicBoolean(false) + /** temporary map of all processed widgets for state parsing */ + abstract val queue: MutableMap + private var customWidgetIndices: Map = StringCreator.defaultMap + private val lock = Mutex() // to guard the indices setter + + override val logger: Logger = LoggerFactory.getLogger(javaClass) + + + suspend fun setCustomWidgetIndices(m: Map){ + lock.withLock { customWidgetIndices = m } + } + + /** + * when compatibility mode is enabled this list will contain the mapping oldId->newlyComputedId + * to transform the model to the current (newer) id computation. + * This mapping is supposed to be used to adapt the action targets in the trace parser (Interaction entries) + */ + private val idMapping: ConcurrentHashMap = ConcurrentHashMap() + + protected fun computeWidget(line: List, id: ConcreteId): Pair { + log("compute widget $id") + return id to StringCreator.parseWidgetPropertyString(line, customWidgetIndices) + } + + @Suppress("FunctionName") + abstract fun P_S_process(s: List, id: ConcreteId, scope: CoroutineScope): Pair + + private fun parseWidget(line: List, scope: CoroutineScope): Pair { + log("parse widget $line") + val idProperty = StringCreator.widgetProperties.find { it.property == Widget::id } + check(idProperty != null) + val id = idProperty.parseValue(line, customWidgetIndices).getValue() as ConcreteId + + return id to queue.computeIfAbsent(id){ + log("parse absent widget properties for $id") + P_S_process(line,id, scope).second + } + } + override val processor: suspend (s: List, scope: CoroutineScope) -> Pair = { s,cs -> parseWidget(s,cs) } + + fun fixedWidgetId(idString: String) = ConcreteId.fromString(idString)?.let{ idMapping[it] ?: it } + fun fixedWidgetId(idString:ConcreteId?) = idString?.let{ idMapping[it] ?: it } + fun addFixedWidgetId(oldId: ConcreteId, newId: ConcreteId) { idMapping[oldId] = newId } + + companion object { + + /** + * this function can be used to automatically adapt the property indicies in the persistated file + * if header.size contains not all persistent entries the respective entries cannot be set in the created Widget. + * Optionally a map of oldName->newName can be given to automatically infere renamed header entries + */ + @JvmStatic fun computeWidgetIndices(header: List, renamed: Map = emptyMap()): Map{ + if(header.size!= StringCreator.annotatedProperties.count()){ + val missing = StringCreator.annotatedProperties.filter { !header.contains(it.annotation.header) && !renamed.containsValue(it.annotation.header) } + println("WARN the given Widget File does not specify all available properties," + + "this may lead to different Widget properties and may require to be parsed in compatibility mode\n missing entries: ${missing.toList()}") + } + val mapping = HashMap() + header.forEachIndexed { index, s -> + val key = renamed[s] ?: s + annotatedProperties.find { it.annotation.header == key }?.let{ // if the entry is no longer in P we simply ignore it + mapping[it] = index + true // need to return something such that ?: print is not called + } ?: println("WARN entry '$key' is no longer contained in the widget properties") + } + return mapping + } + } +} + +internal class WidgetParserS(override val model: Model, + override val compatibilityMode: Boolean = false, + override val enableChecks: Boolean = true): WidgetParserI(){ + + override fun P_S_process(s: List, id: ConcreteId, scope: CoroutineScope): Pair = runBlocking{ computeWidget(s,id) } + + override suspend fun getElem(e: Pair): UiElementPropertiesI = e.second + + override val queue: MutableMap = HashMap() +} + +internal class WidgetParserP(override val model: Model, + override val compatibilityMode: Boolean = false, + override val enableChecks: Boolean = true): WidgetParserI>(){ + + + override fun P_S_process(s: List, id: ConcreteId, scope: CoroutineScope): Pair> + = id to scope.async(CoroutineName("parseWidget $id")){ + computeWidget(s,id).second + } + + override suspend fun getElem(e: Pair>): UiElementPropertiesI = e.second.await() + + override val queue: MutableMap> = ConcurrentHashMap() +} diff --git a/src/main/kotlin/org/droidmate/explorationModel/utils.kt b/src/main/kotlin/org/droidmate/explorationModel/utils.kt new file mode 100644 index 0000000..d13bca1 --- /dev/null +++ b/src/main/kotlin/org/droidmate/explorationModel/utils.kt @@ -0,0 +1,140 @@ +@file:Suppress("unused") + +package org.droidmate.explorationModel + +import org.droidmate.deviceInterface.exploration.Rectangle +import org.droidmate.deviceInterface.exploration.UiElementPropertiesI +import java.nio.charset.Charset +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.* +import kotlin.system.measureNanoTime + +/** custom type aliases and extension functions */ +typealias DeactivatableFlag = Boolean? + +data class ConcreteId(val uid: UUID, val configId: UUID) { + override fun toString(): String = "${uid}_$configId" + + companion object { + fun fromString(s: String): ConcreteId? = + if(s == "null") null else s.split("_").let { ConcreteId(UUID.fromString(it[0]), UUID.fromString(it[1])) } + } +} + +internal operator fun UUID.plus(uuid: UUID?): UUID { + return if(uuid == null) this + else UUID(this.mostSignificantBits + uuid.mostSignificantBits, this.leastSignificantBits + uuid.mostSignificantBits) +} +internal operator fun UUID.plus(id: Int): UUID { + return UUID(this.mostSignificantBits + id, this.leastSignificantBits + id) +} + +fun String.toUUID(): UUID = UUID.nameUUIDFromBytes(trim().toLowerCase().toByteArray(Charset.forName("UTF-8"))) +fun Int.toUUID(): UUID = UUID.nameUUIDFromBytes(toString().toByteArray(Charset.forName("UTF-8"))) +fun center(c:Int, d:Int):Int = c+(d/2) + +val emptyUUID: UUID = UUID.nameUUIDFromBytes(byteArrayOf()) +fun String.asUUID(): UUID? = if(this == "null") null else UUID.fromString(this) +//typealias ConcreteId = Pair +//fun ConcreteId.toString() = "${first}_$second" // mainly for nicer debugging strings +/** custom dumpString method used for model dump & load **/ +//fun ConcreteId.dumpString() = "${first}_$second" +val emptyId = ConcreteId(emptyUUID, emptyUUID) + +private const val datePattern = "ddMM-HHmmss" +internal fun timestamp(): String = DateTimeFormatter.ofPattern(datePattern).format(LocalDateTime.now()) + + +/** debug functions */ + +internal const val debugOutput = true +const val measurePerformance = true + +@Suppress("ConstantConditionIf") +fun debugOut(msg:String, enabled: Boolean = true) { if (debugOutput && enabled) println(msg) } + +inline fun nullableDebugT(msg: String, block: () -> T?, timer: (Long) -> Unit = {}, inMillis: Boolean = false): T? { + var res: T? = null + @Suppress("ConstantConditionIf") + if (measurePerformance) { + measureNanoTime { + res = block.invoke() + }.let { + timer(it) + println("time ${if (inMillis) "${it / 1000000.0} ms" else "${it / 1000.0} ns/1000"} \t $msg") + } + } else res = block.invoke() + return res +} + +inline fun debugT(msg: String, block: () -> T, timer: (Long) -> Unit = {}, inMillis: Boolean = false): T { + return nullableDebugT(msg, block, timer, inMillis)!! +} + +fun Collection.firstCenter() = firstOrNull()?.center +fun Collection.firstOrEmpty() = firstOrNull() ?: Rectangle(0,0,0,0) + +internal class UiElementP( properties: Map) : UiElementPropertiesI { + /** no meta information is persisted */ + override val metaInfo: List = emptyList() + + override val isKeyboard: Boolean by properties + override val hintText: String by properties + override val inputType: Int by properties + override val text: String by properties + override val contentDesc: String by properties + override val resourceId: String by properties + override val className: String by properties + override val packageName: String by properties + override val isInputField: Boolean by properties + override val isPassword: Boolean by properties + override val visibleBounds: Rectangle by properties + override val boundaries: Rectangle by properties + override val clickable: Boolean by properties + override val checked: Boolean? by properties + override val longClickable: Boolean by properties + override val focused: Boolean? by properties + override val selected: Boolean by properties + override val scrollable: Boolean by properties + override val xpath: String by properties + override val idHash: Int by properties + override val parentHash: Int by properties + override val childHashes: List by properties + override val definedAsVisible: Boolean by properties + override val enabled: Boolean by properties + override val imgId: Int by properties + override val visibleAreas: List by properties + override val hasUncoveredArea: Boolean by properties +} + +object DummyProperties: UiElementPropertiesI { + override val hintText: String = "Dummy-hintText" + override val inputType: Int = 0 + override val imgId: Int = 0 + override val visibleBounds: Rectangle = Rectangle(0,0,0,0) + override val hasUncoveredArea: Boolean = false + override val boundaries: Rectangle = Rectangle(0,0,0,0) + override val visibleAreas: List = listOf(Rectangle(0,0,0,0)) + override val metaInfo: List = emptyList() + override val isKeyboard: Boolean = false + override val text: String = "Dummy-Widget" + override val contentDesc: String = "No-contentDesc" + override val checked: Boolean? = null + override val resourceId: String = "No-resourceId" + override val className: String = "No-className" + override val packageName: String = "No-packageName" + override val enabled: Boolean = false + override val isInputField: Boolean = false + override val isPassword: Boolean = false + override val clickable: Boolean = false + override val longClickable: Boolean = false + override val scrollable: Boolean = false + override val focused: Boolean? = null + override val selected: Boolean = false + override val definedAsVisible: Boolean = false + override val xpath: String = "No-xPath" + override val idHash: Int = 0 + override val parentHash: Int = 0 + override val childHashes: List = emptyList() +} \ No newline at end of file diff --git a/src/main/resources/runtime/defaultModelConfig.properties b/src/main/resources/runtime/defaultModelConfig.properties new file mode 100644 index 0000000..6835847 --- /dev/null +++ b/src/main/resources/runtime/defaultModelConfig.properties @@ -0,0 +1,29 @@ +# suppress inspection "UnusedProperty" for whole file + +Core.debugMode=false + +# per default this will be overwritten with $default_output/model +ModelProperties.path.defaultBaseDir=out/model +ModelProperties.path.statesSubDir=states +ModelProperties.path.imagesSubDir=images +ModelProperties.path.cleanDirs=true + +ModelProperties.dump.onEachAction=true +ModelProperties.dump.sep=; +ModelProperties.dump.stateFileExtension=.csv +ModelProperties.dump.traceFileExtension=.csv + #.txt +ModelProperties.dump.traceFilePrefix=trace + +ModelProperties.imgDump.states=true +# if false all dump.widget.* properties are disabled as well, default false because they can be easily recomputed +ModelProperties.imgDump.widgets=false +# dumping interacted currently not available (would have to reload state and cut target widget bounds) +# ModelProperties.imgDump.widget.Interacted=true +ModelProperties.imgDump.widget.onlyWhenNoText=true +ModelProperties.imgDump.widget.interactable=true +ModelProperties.imgDump.widget.nonInteractable=false + +# Features +ModelProperties.Features.statementCoverage=true +ModelProperties.Features.statementCoverageDir=instrumentation-logs diff --git a/src/test/kotlin/org/droidmate/explorationModel/ModelTester.kt b/src/test/kotlin/org/droidmate/explorationModel/ModelTester.kt new file mode 100644 index 0000000..1d78108 --- /dev/null +++ b/src/test/kotlin/org/droidmate/explorationModel/ModelTester.kt @@ -0,0 +1,43 @@ +package org.droidmate.explorationModel + +import org.droidmate.explorationModel.interaction.Widget +import org.droidmate.explorationModel.retention.StringCreator +import org.droidmate.explorationModel.retention.loading.dataString +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import java.util.* + +/** + * test cases: + * - UiElementP UID computation + * - Widget UID computation + * - dump-String computations of: Widget & Interaction + * - actionData creation/ model update for mocked ActionResult + * (- ignore edit-field mechanism) + * (- re-identification of State for ignored properties variations) + */ +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +@RunWith(JUnit4::class) +class ModelTester: TestI, TestModel by DefaultTestModel() { + + @Test + fun widgetUidTest(){ + val emptyWidget = Widget.emptyWidget + expect(parentWidget.configId.toString(), "20ef5802-33dc-310b-8efc-c791586ede85") // quickFix due to new UiElementP constructor + expect(emptyWidget.configId, parentWidget.configId) + expect(parentWidget.id.toString(),"70a351cc-119f-38b5-899d-5a4728752672_${parentWidget.configId}") + expect(testWidget.parentId, emptyWidget.id) + + expect(testWidget.configId, UUID.fromString("5d802df8-481b-3882-9c3b-95ea87b08a03")) + } + + @Test + fun widgetDumpTest(){ + expect(testWidget.dataString(";"), testWidgetDumpString) + val properties =StringCreator.parseWidgetPropertyString(testWidget.dataString(";").split(";"), StringCreator.defaultMap) + expect(Widget(properties, testWidget.parentId).dataString(";"),testWidget.dataString(";")) + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/droidmate/explorationModel/TestI.kt b/src/test/kotlin/org/droidmate/explorationModel/TestI.kt new file mode 100644 index 0000000..71d7ad2 --- /dev/null +++ b/src/test/kotlin/org/droidmate/explorationModel/TestI.kt @@ -0,0 +1,14 @@ +package org.droidmate.explorationModel + +import junit.framework.TestCase + +interface TestI { + fun expect(res:T, ref: T){ + val refSplit = ref.toString().split(";") + val diff = res.toString().split(";").mapIndexed { index, s -> + if(refSplit.size>index) s.replace(refSplit[index],"#CORRECT#") + else s + } + TestCase.assertTrue("expected \n${ref.toString()} \nbut result was \n${res.toString()}\n DIFF = $diff", res == ref) + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/droidmate/explorationModel/TestModel.kt b/src/test/kotlin/org/droidmate/explorationModel/TestModel.kt new file mode 100644 index 0000000..8cda144 --- /dev/null +++ b/src/test/kotlin/org/droidmate/explorationModel/TestModel.kt @@ -0,0 +1,51 @@ +package org.droidmate.explorationModel + +import org.droidmate.deviceInterface.exploration.Rectangle +import org.droidmate.deviceInterface.exploration.UiElementPropertiesI +import org.droidmate.explorationModel.interaction.* +import org.droidmate.explorationModel.retention.StringCreator +import org.droidmate.explorationModel.retention.StringCreator.parsePropertyString +import java.time.LocalDateTime + +internal interface TestModel{ + val parentData: UiElementPropertiesI get() = DummyProperties + val parentWidget: Widget get() = Widget(parentData, null) + val testWidgetData: UiElementPropertiesI + val testWidget: Widget get() = Widget(testWidgetData, parentWidget.id) + val testWidgetDumpString: String +} + +typealias TestAction = Interaction +@JvmOverloads fun createTestAction(targetWidget: Widget?=null, oldState: ConcreteId = emptyId, + nextState: ConcreteId = emptyId, actionType:String = "TEST_ACTION", + data: String = ""): TestAction + = Interaction(actionType = actionType, target = targetWidget, startTimestamp = LocalDateTime.MIN, data = data, + endTimestamp = LocalDateTime.MIN, successful = true, exception = "test action", prevState = oldState, resState = nextState) + + +internal class DefaultTestModel: TestModel { + override val testWidgetData by lazy{ + val properties = StringCreator.createPropertyString(parentWidget,";").split(";") + val namePropMap = StringCreator.baseAnnotations.parsePropertyString(properties, StringCreator.defaultMap).toMutableMap() + namePropMap[Widget::text.name] = "text-mock" + namePropMap[Widget::contentDesc.name] = "description-mock" + namePropMap[Widget::resourceId.name] = "resourceId-mock" + namePropMap[Widget::className.name] = "class-mock" + namePropMap[Widget::packageName.name] = "package-mock" + namePropMap[Widget::enabled.name] = true + namePropMap[Widget::clickable.name] = true + namePropMap[Widget::definedAsVisible.name] = true + namePropMap[Widget::boundaries.name] = Rectangle(11,136,81,51) + namePropMap[Widget::visibleAreas.name] = listOf(Rectangle(11,136,81,51)) + namePropMap[Widget::visibleBounds.name] = Rectangle(11,136,81,51) + UiElementP(namePropMap) + } + + // per default we don't want to re-generate the widgets on each access, therefore make them persistent values + override val testWidget: Widget by lazy{ super.testWidget } + override val parentWidget: Widget by lazy{ super.parentWidget } + + override val testWidgetDumpString = "44bab742-3cdf-3af5-94ad-9e8fb9752e53_5d802df8-481b-3882-9c3b-95ea87b08a03;" + + "class-mock;text-mock;Dummy-hintText;description-mock;0;disabled;false;11:136:81:51;11:136:81:51;[];true;true;true;disabled;" + + "0;0;false;false;false;false;package-mock;0;resourceId-mock;false;false;[11:136:81:51];No-xPath" +} \ No newline at end of file diff --git a/src/test/kotlin/org/droidmate/explorationModel/retention/loading/ModelLoadTester.kt b/src/test/kotlin/org/droidmate/explorationModel/retention/loading/ModelLoadTester.kt new file mode 100644 index 0000000..90c9c93 --- /dev/null +++ b/src/test/kotlin/org/droidmate/explorationModel/retention/loading/ModelLoadTester.kt @@ -0,0 +1,99 @@ +package org.droidmate.explorationModel.retention.loading + +import kotlinx.coroutines.runBlocking +import org.droidmate.deviceInterface.exploration.ActionType +import org.droidmate.deviceInterface.exploration.Click +import org.droidmate.explorationModel.DefaultTestModel +import org.droidmate.explorationModel.TestI +import org.droidmate.explorationModel.TestModel +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import org.droidmate.explorationModel.config.ModelConfig +import org.droidmate.explorationModel.createTestAction +import org.droidmate.explorationModel.interaction.* +import org.droidmate.explorationModel.retention.StringCreator +import java.util.* + +private val config = ModelConfig("JUnit", true) + +fun Widget.dataString(sep: String) = StringCreator.createPropertyString(this,sep) +fun Interaction.actionString(sep: String) = StringCreator.createActionString(this,sep) + +/** verify the ModelLoader correctly initializes/loads a model by using + * - mocked model (mock the dump-file nlpText read) + * - loading real model dump files & verifying resulting model + * - dumping and loading the same model => verify equality + * - test watcher are correctly updated during model loading + * + * REMARK for mockito to work it is essential that all mocked/spied classes and methods have the `open` modifier + */ +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +@RunWith(JUnit4::class) +class ModelLoadTester: TestI, TestModel by DefaultTestModel(), ModelLoaderTI by ModelLoaderT(config) { + private val testState = State(setOf(testWidget), isHomeScreen = false) + private val states = listOf(testState, State.emptyState) + + @Test + fun widgetParsingTest() = runBlocking{ + expect(Widget(parseWidget(testWidget)!!,testWidget.parentId).dataString(sep) + ,testWidget.dataString(sep)) + } + + @Test fun loadTest(){ //FIXME + val actions = listOf(createTestAction(testWidget, testState.stateId)) + val model = execute(listOf(actions),states) + runBlocking { + expect(model.getState(testState.stateId)!!.widgetsDump("\t"),testState.widgetsDump("\t")) + model.getWidgets().let { widgets -> + expect(widgets.size, 1) + expect(widgets.first().dataString("\t"),testWidget.dataString("\t")) + } + } + model.getPaths().let{ traces -> + expect(traces.size,1) + traces.first().getActions().let{ _actions -> + expect(_actions.size,1) + expect(_actions.first().actionString(sep),actions.first().actionString(sep)) + } + } + println(model) + } + + @Test fun loadMultipleActionsTest(){ + val actions = LinkedList().apply { + add(createTestAction(nextState = testState.stateId, actionType = "ResetAppExplorationAction")) + for(i in 1..5) + add(createTestAction(oldState = testState.stateId, nextState = testState.stateId, actionType = Click.name, + targetWidget = testWidget, data = "$i test action")) + add(createTestAction(oldState = testState.stateId, actionType = ActionType.Terminate.name)) + } + val model = execute(listOf(actions),states) + + println(model) + model.getPaths().let{ traces -> + expect(traces.size,1) + traces.first().getActions().let{ _actions -> + expect(_actions.size,7) + _actions.forEachIndexed { index, action -> + println(action.actionString(sep)) + expect(action.actionString(sep), actions[index].actionString(sep)) + } + } + } + } + +// @Test fun debugStateParsingTest(){ +// testTraces = emptyList() +// testStates = emptyList() +// val state = runBlocking{ +// parseState(fromString("fa5d6ec4-129e-cde6-cfbf-eb837096de60_829a5484-73d6-ba71-57fc-d143d1cecaeb")) } +// println(state) +// } + //test dumped state f7acfd36-d72b-3b6b-cd0f-79f635234be5, 3243aafc-d785-0cc4-07a6-27bc357d1d3e + + +} + diff --git a/src/test/kotlin/org/droidmate/explorationModel/retention/loading/ModelLoaderTI.kt b/src/test/kotlin/org/droidmate/explorationModel/retention/loading/ModelLoaderTI.kt new file mode 100644 index 0000000..cb1c600 --- /dev/null +++ b/src/test/kotlin/org/droidmate/explorationModel/retention/loading/ModelLoaderTI.kt @@ -0,0 +1,162 @@ +package org.droidmate.explorationModel.retention.loading + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.produce +import org.droidmate.deviceInterface.exploration.UiElementPropertiesI +import org.droidmate.explorationModel.* +import org.droidmate.explorationModel.config.ConfigProperties +import org.droidmate.explorationModel.config.ModelConfig +import org.droidmate.explorationModel.interaction.Interaction +import org.droidmate.explorationModel.interaction.State +import org.droidmate.explorationModel.interaction.Widget +import org.droidmate.explorationModel.retention.StringCreator +import java.nio.file.Path +import java.nio.file.Paths +import java.util.* +import kotlin.coroutines.coroutineContext + + +/** test interface for the model loader, which cannot be done with mockito due to coroutine incompatibility */ +internal interface ModelLoaderTI{ + val sep: String + var testTraces: List> + var testStates: Collection + + fun execute(testTraces: List>, testStates: Collection, watcher: LinkedList = LinkedList()): Model + suspend fun parseWidget(widget: Widget): UiElementPropertiesI? + + /** REMARK these are state dependent => use very carefully in Unit-Tests */ + val actionParser: suspend (List,CoroutineScope) -> Pair + suspend fun parseState(stateId: ConcreteId): State + +} + +class TestReader(config: ModelConfig): ContentReader(config){ + lateinit var testStates: Collection + lateinit var testTraces: List> + private val traceContents: (idx: Int) -> List = { idx -> + testTraces[idx].map { actionData -> StringCreator.createActionString(actionData, ";").also { log(it) } } } + + private fun headerPlusString(s:List,skip: Long):List = LinkedList().apply { + add(StringCreator.widgetHeader(config[ConfigProperties.ModelProperties.dump.sep])) + addAll(s) + }.let { + it.subList(skip.toInt(),it.size) + } + + override fun getFileContent(path: Path, skip: Long): List? = path.fileName.toString().let { name -> + log("getFileContent for ${path.toUri()}") + when { + (name.startsWith(config[ConfigProperties.ModelProperties.dump.traceFilePrefix])) -> + traceContents(name.removePrefix(config[ConfigProperties.ModelProperties.dump.traceFilePrefix]).toInt()) + name.contains("fa5d6ec4-129e-cde6-cfbf-eb837096de60_829a5484-73d6-ba71-57fc-d143d1cecaeb") -> + headerPlusString(debugString.split("\n"), skip) + else -> + headerPlusString( testStates.find { s -> + s.stateId == ConcreteId.fromString(name.removeSuffix(config[ConfigProperties.ModelProperties.dump.traceFileExtension])) }!! + .widgetsDump(config[ConfigProperties.ModelProperties.dump.sep]),skip) + } + } + + override fun getStateFile(stateId: ConcreteId): Pair{ + val s = testStates.find { s -> s.stateId == stateId } + if(s == null) + println("debug error") + return s!!.let{ + Pair(Paths.get(config.widgetFile(stateId,it.isHomeScreen)),it.isHomeScreen) + } + } + +} + +@ExperimentalCoroutinesApi +internal class ModelLoaderT(override val config: ModelConfig): ModelParserP(config,enableChecks = true), ModelLoaderTI { + override val sep: String= config[ConfigProperties.ModelProperties.dump.sep] + + /** creating test environment */ + override val enableChecks: Boolean = true + override val compatibilityMode: Boolean = false + override val enablePrint: Boolean = false + override val reader: TestReader = TestReader(config) + override val isSequential: Boolean = false + override fun log(msg: String) = println("TestModelLoader[${Thread.currentThread().name}] $msg") + + /** implementing ModelParser default methods */ + override val widgetParser by lazy { WidgetParserP(model, compatibilityMode, enableChecks) } + override val stateParser by lazy { StateParserP(widgetParser, reader, model, compatibilityMode, enableChecks) } + + /** custom test environment */ +// override val actionParser: suspend (List,CoroutineScope) -> Pair = processor + override val actionParser: suspend (List,CoroutineScope) -> Pair = { args,scope -> processor(args,scope).await() } + override var testStates: Collection + get() = reader.testStates + set(value) { reader.testStates = value} + override var testTraces: List> + get() = reader.testTraces + set(value) { reader.testTraces = value} + + override suspend fun traceProducer() = GlobalScope.produce(Dispatchers.Default, capacity = 5, block = { + log("Produce trace paths") + testTraces.forEach { log(it.toString() + "\n") } + for (i in 0 until testTraces.size) { + send(Paths.get(config[ConfigProperties.ModelProperties.dump.traceFilePrefix] + i.toString())) + } + }) + + override fun execute(testTraces: List>, testStates: Collection, watcher: LinkedList): Model { +// logcat(testActions.) + this.testTraces = testTraces + this.testStates = testStates + return runBlocking { loadModel(watcher) } + } + + override suspend fun parseWidget(widget: Widget): UiElementPropertiesI? = widgetParser.getElem( widgetParser.processor( + StringCreator.createPropertyString(widget, sep).split(sep), CoroutineScope(coroutineContext) + )) + + override suspend fun parseState(stateId: ConcreteId): State = stateParser.getElem( stateParser.parseIfAbsent(coroutineContext)(stateId) ) +} + +private const val debugString = "881086d0-66da-39d3-89a7-3ef465ab4971;ccff4dcd-b1ec-3ebf-b2e0-8757b1f8119f;android.view.ViewGroup;true;;;null;true;true;true;false;false;disabled;false;false;false;789;63;263;263;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.HorizontalScrollView[1]/android.widget.LinearLayout[1]/android.view.ViewGroup[4];false;ch.bailu.aat\n" + + "8945671d-0726-31ff-ae18-48c904674dbf;b940bd4d-7415-39c7-9410-fb90c9e00eb2;android.widget.LinearLayout;false;;;null;true;true;false;false;false;disabled;disabled;false;false;0;0;1080;1794;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1];false;ch.bailu.aat\n" + + "8c578de3-7278-3da4-88d7-63ea86c5cf20;5316bbd9-b134-3beb-8afd-274c882125a5;android.widget.TextView;false;GPS;;null;true;true;false;false;false;disabled;disabled;false;false;526;89;263;51;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.HorizontalScrollView[1]/android.widget.LinearLayout[1]/android.view.ViewGroup[3]/android.widget.TextView[1];true;ch.bailu.aat\n" + + "94e3c002-756a-3f54-b9bb-f568589de199;fe8404e6-0a37-33c4-96e2-fee2823f7e7c;android.widget.LinearLayout;true;;;null;true;true;true;false;false;disabled;disabled;false;false;0;694;1080;184;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[3];false;ch.bailu.aat\n" + + "98977b60-c508-3184-bb1b-767e07fd60b2;5130c087-5ebe-3d7b-a372-0201b7ff3260;android.view.ViewGroup;true;;;null;true;true;true;false;false;disabled;disabled;false;false;0;63;263;263;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.HorizontalScrollView[1]/android.widget.LinearLayout[1]/android.view.ViewGroup[1];false;ch.bailu.aat\n" + + "98977b60-c508-3184-bb1b-767e07fd60b2;ee538e23-0b90-31e8-ab6a-c02122f3129b;android.widget.ImageButton;false;;;null;true;true;false;false;false;disabled;false;false;false;0;63;263;263;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.HorizontalScrollView[1]/android.widget.LinearLayout[1]/android.view.ViewGroup[1]/android.widget.ImageButton[1];true;ch.bailu.aat\n" + + "ac1c14c5-a615-30ab-af76-c98660a12b69;95621660-4306-3de3-8eb5-51bb528b6d71;android.widget.LinearLayout;true;;;null;true;true;true;false;false;disabled;disabled;false;false;0;1062;1080;184;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[5];false;ch.bailu.aat\n" + + "b6a022f4-dfee-3c05-8e07-0416456cdb3b;df8b2716-3f45-3e46-8123-a8e45fa557d1;android.widget.LinearLayout;false;;;null;true;true;false;false;false;disabled;disabled;false;false;0;63;1080;263;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1];false;ch.bailu.aat\n" + + "c75b614d-195e-3620-8455-03ee9a04c998;11362e14-0fa4-389f-a1d2-ecc5e45a8913;android.view.ViewGroup;false;;;null;true;true;false;false;false;disabled;false;false;false;526;63;263;263;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.HorizontalScrollView[1]/android.widget.LinearLayout[1]/android.view.ViewGroup[3];false;ch.bailu.aat\n" + + "cb11347e-8d94-36b7-aa64-57c5107f7bda;60c45577-3722-3139-b45b-1265885870cc;android.widget.LinearLayout;true;;;null;true;true;true;false;false;disabled;disabled;false;false;0;326;1080;184;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1];false;ch.bailu.aat\n" + + "d0834fce-c633-3785-ae74-9c8f5464f6f6;f81a0bad-addb-3b77-a6ec-ad069b701ec4;android.widget.TextView;false;Preferences;;null;true;true;false;false;false;disabled;disabled;false;false;26;1640;1028;71;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[8]/android.widget.TextView[1];true;ch.bailu.aat\n" + + "d15305d7-a4e3-3e02-889c-74a5ef542f36;a1bb0ab1-d08c-3815-9c86-6fe72e14dce0;android.widget.TextView;false;Off;;null;true;true;false;false;false;disabled;disabled;false;false;526;140;263;109;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.HorizontalScrollView[1]/android.widget.LinearLayout[1]/android.view.ViewGroup[3]/android.widget.TextView[2];true;ch.bailu.aat\n" + + "d15305d7-a4e3-3e02-889c-74a5ef542f36;81996af2-14cf-3328-9fbc-3a91f148e842;android.widget.TextView;false;Off;;null;true;true;false;false;false;disabled;disabled;false;false;789;140;263;109;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.HorizontalScrollView[1]/android.widget.LinearLayout[1]/android.view.ViewGroup[4]/android.widget.TextView[2];true;ch.bailu.aat\n" + + "d7781b21-72b7-3cc5-b085-d0e8673189cc;5b4b3359-7aed-380f-906d-73f34aa97d8b;android.widget.TextView;false;[6.8 bicycling, leisure, moderate effort];;null;true;true;false;false;false;disabled;disabled;false;false;26;423;1028;51;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.TextView[2];true;ch.bailu.aat\n" + + "d7ec5ea0-dee0-3157-b34a-cace17bbfcda;e58d79eb-d661-3adc-8c72-6419a312bdf6;android.widget.TextView;false;/storage/emulated/0/Android/data/ch.bailu.aat/files/overlay;;null;true;true;false;false;false;disabled;disabled;false;false;26;1343;1028;51;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[6]/android.widget.TextView[2];true;ch.bailu.aat\n" + + "e1fd48b6-5206-3209-b012-0234165c2338;e3d1e90c-d487-3b92-b96a-ef7e9150a691;android.view.View;false;;;null;true;true;false;false;false;disabled;disabled;false;false;263;63;263;263;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.HorizontalScrollView[1]/android.widget.LinearLayout[1]/android.view.View[2];true;ch.bailu.aat\n" + + "e8104e4b-a7e2-3734-9df3-2e3ab6c01792;8893007d-ffcf-34fe-9add-1887913a4736;android.widget.TextView;false;Cockpit A;;null;true;true;false;false;false;disabled;disabled;false;false;26;536;1028;71;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[2]/android.widget.TextView[1];true;ch.bailu.aat\n" + + "ecfc2dff-e568-310a-a7db-c6d3724cfde2;ecbb3edc-49bc-3dfe-acaa-7c0b835b4f6f;android.widget.TextView;false;Activity;;null;true;true;false;false;false;disabled;disabled;false;false;26;352;1028;71;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.TextView[1];true;ch.bailu.aat\n" + + "f2c10ab9-7a63-30bb-82d7-a09cbff36b38;714d99bc-cfee-3d0f-91ca-5be05c1c0afa;android.widget.TextView;false;Track list;;null;true;true;false;false;false;disabled;disabled;false;false;26;1088;1028;71;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[5]/android.widget.TextView[1];true;ch.bailu.aat\n" + + "f7592a2a-ddc6-3c4a-9a33-9caa7ee61b8f;1f4027b6-5838-3822-8913-b50c95c6e627;android.widget.TextView;false;Search with OSM-Nominatim.;;null;true;true;false;false;false;disabled;disabled;false;false;26;1527;1028;51;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[7]/android.widget.TextView[2];true;ch.bailu.aat\n" + + "0829ea67-3405-3d66-a6bf-87096b215dc1;581aa529-492b-3bbf-8d78-2d988e9d5824;android.widget.TextView;false;Fullscreen;;null;true;true;false;false;false;disabled;disabled;false;false;26;607;1028;51;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[2]/android.widget.TextView[2];true;ch.bailu.aat\n" + + "0e07d018-f6e0-3e47-a15e-b305afb237ad;4250070d-7e4e-3a7d-af25-ea8cc7bb0964;android.widget.ScrollView;true;;;null;true;true;false;false;true;disabled;false;false;false;0;326;1080;1468;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2];false;ch.bailu.aat\n" + + "0e07d018-f6e0-3e47-a15e-b305afb237ad;c1243b29-842e-365e-bbfe-ac955cedfe7a;android.widget.LinearLayout;false;;;null;true;true;false;false;false;disabled;disabled;false;false;0;326;1080;1468;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1];false;ch.bailu.aat\n" + + "18a37c8e-4de4-35d2-bc57-e1e7e58c7c1f;969ef865-cd8e-39e7-b9c2-0738dc0157dd;android.widget.LinearLayout;true;;;null;true;true;true;false;false;disabled;disabled;false;false;0;878;1080;184;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[4];false;ch.bailu.aat\n" + + "26b8a5f8-cbb6-32e9-86c8-eed4b88cc0bd;a794d91d-6079-333e-bac6-d915fa0fa10e;android.widget.TextView;false;Tracker;;null;true;true;false;false;false;disabled;disabled;false;false;789;89;263;51;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.HorizontalScrollView[1]/android.widget.LinearLayout[1]/android.view.ViewGroup[4]/android.widget.TextView[1];true;ch.bailu.aat\n" + + "2b8f408d-a314-3678-af6b-a0e581dd3fac;3180b714-3c0e-3c22-9ac4-5a88fe13178b;android.widget.TextView;false;;;null;true;true;false;false;false;disabled;disabled;false;false;26;1711;1028;51;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[8]/android.widget.TextView[2];true;ch.bailu.aat\n" + + "39ac6ebd-7390-3723-a0ed-aa7633afb074;1dbbe5d0-816d-33ed-81ab-2125f3152478;android.widget.LinearLayout;true;;;null;true;true;true;false;false;disabled;disabled;false;false;0;510;1080;184;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[2];false;ch.bailu.aat\n" + + "46f3ea05-6caa-3126-b91f-3f70beea068c;a6830ffd-440b-3651-b2b0-a6c2e19b9b3b;android.widget.TextView;false;Map;;null;true;true;false;false;false;disabled;disabled;false;false;26;904;1028;71;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[4]/android.widget.TextView[1];true;ch.bailu.aat\n" + + "47da2762-fa16-34ff-a91c-cf89e7a94fcf;94241dfb-6fb1-3166-bf93-6d065ac59233;android.widget.TextView;false;;;null;true;true;false;false;false;disabled;disabled;false;false;526;249;263;51;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.HorizontalScrollView[1]/android.widget.LinearLayout[1]/android.view.ViewGroup[3]/android.widget.TextView[3];true;ch.bailu.aat\n" + + "47da2762-fa16-34ff-a91c-cf89e7a94fcf;dd227b51-bb42-3def-9fc4-8f75a9d880b5;android.widget.TextView;false;;;null;true;true;false;false;false;disabled;disabled;false;false;789;249;263;51;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.HorizontalScrollView[1]/android.widget.LinearLayout[1]/android.view.ViewGroup[4]/android.widget.TextView[3];true;ch.bailu.aat\n" + + "47e0e19f-4bb0-3fc4-a430-0024d15e893d;cebcb059-2336-3a58-afc7-514b1f778543;android.widget.TextView;false;Cockpit B;;null;true;true;false;false;false;disabled;disabled;false;false;26;720;1028;71;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[3]/android.widget.TextView[1];true;ch.bailu.aat\n" + + "51fbca67-14e9-33e7-801a-05314210ae86;574013da-af06-3400-bcf0-983ab094b500;android.widget.TextView;false;Splitscreen;;null;true;true;false;false;false;disabled;disabled;false;false;26;791;1028;51;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[3]/android.widget.TextView[2];true;ch.bailu.aat\n" + + "52be7836-4733-3775-b2db-af0cfb3f00f4;7ad6e4ab-6961-3c2a-ac58-d5289b5e3a91;android.widget.LinearLayout;true;;;null;true;true;true;false;false;disabled;disabled;false;false;0;1430;1080;184;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[7];false;ch.bailu.aat\n" + + "60ef277d-0ef8-3452-8652-96a8a88c5dd9;1ad98a16-2b21-3335-b2ae-d4034e5f7e2d;android.widget.TextView;false;;;null;true;true;false;false;false;disabled;disabled;false;false;26;975;1028;51;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[4]/android.widget.TextView[2];true;ch.bailu.aat\n" + + "67e82420-9cca-3a57-bcd7-6f0e4fcb9c33;e1c0e4b4-fc9f-3a48-a39d-32e4a3eb2b7c;android.widget.LinearLayout;true;;;null;true;true;true;false;false;disabled;disabled;false;false;0;1246;1080;184;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[6];false;ch.bailu.aat\n" + + "68920d13-d016-3bfa-b161-9a3ac716fa35;38a64cd7-a898-3052-9cf2-b2c6cb605297;android.widget.TextView;false;Map Search;;null;true;true;false;false;false;disabled;disabled;false;false;26;1456;1028;71;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[7]/android.widget.TextView[1];true;ch.bailu.aat\n" + + "6ac1ff6b-2a02-36fa-8d06-794e3bf2f859;710abab8-dfed-3fb0-996e-ee03dd6afb01;android.widget.TextView;false;/storage/emulated/0/Android/data/ch.bailu.aat/files/activity0;;null;true;true;false;false;false;disabled;disabled;false;false;26;1159;1028;51;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[5]/android.widget.TextView[2];true;ch.bailu.aat\n" + + "6e989f73-0582-3880-80b1-036719485c8b;b68cc81d-2a64-3589-871d-28d788212cf0;android.widget.FrameLayout;false;;;null;true;true;false;false;false;disabled;disabled;false;false;0;63;1080;1731;android:id/content;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1];false;ch.bailu.aat\n" + + "6e989f73-0582-3880-80b1-036719485c8b;10fd09d8-338d-3953-8e11-433d10b97c78;android.widget.LinearLayout;false;;;null;true;true;false;false;false;disabled;disabled;false;false;0;63;1080;1731;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1];false;ch.bailu.aat\n" + + "7945d0cd-90fb-3a29-b3a6-f621d3e055c9;e6f0c918-2d21-3f77-b7ff-0eed5af34f03;android.widget.LinearLayout;true;;;null;true;true;true;false;false;disabled;disabled;false;false;0;1614;1080;180;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[8];false;ch.bailu.aat\n" + + "7a88a92c-fbb9-324e-95e8-fdee7d1c6bfa;17fbd661-dd74-329c-9ef8-49d19f152d7d;android.widget.HorizontalScrollView;false;;;null;true;true;false;false;false;disabled;false;false;false;0;63;1052;263;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.HorizontalScrollView[1];false;ch.bailu.aat\n" + + "7a88a92c-fbb9-324e-95e8-fdee7d1c6bfa;6d86439e-6934-3552-8b50-78641366fdf4;android.widget.LinearLayout;false;;;null;true;true;false;false;false;disabled;disabled;false;false;0;63;1052;263;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.LinearLayout[1]/android.widget.HorizontalScrollView[1]/android.widget.LinearLayout[1];false;ch.bailu.aat\n" + + "7d67ebc3-437f-3daf-8b18-57993ef4dcc9;ee5c0057-4183-3fc0-a96d-03b6955f0ae3;android.widget.TextView;false;Overlay list;;null;true;true;false;false;false;disabled;disabled;false;false;26;1272;1028;71;;//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]/android.widget.ScrollView[2]/android.widget.LinearLayout[1]/android.widget.LinearLayout[6]/android.widget.TextView[1];true;ch.bailu.aat" \ No newline at end of file