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