Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: Corellium adapters #1880

Merged
merged 7 commits into from
May 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions corellium/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**/*.properties
6 changes: 3 additions & 3 deletions corellium/adapter/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
kotlin(Plugins.Kotlin.PLUGIN_JVM)
kotlin(Plugins.Kotlin.PLUGIN_SERIALIZATION) version Versions.KOTLIN
}

repositories {
Expand All @@ -15,12 +14,13 @@ tasks.withType<KotlinCompile> { kotlinOptions.jvmTarget = "1.8" }

dependencies {
implementation(project(":corellium:api"))
implementation(project(":corellium:client"))

implementation(Dependencies.KOTLIN_COROUTINES_CORE)
implementation(Dependencies.DEX_TEST_PARSER)
implementation(Dependencies.APK_PARSER)

testImplementation(Dependencies.JUNIT)
testImplementation(Dependencies.MOCKK)
testImplementation(Dependencies.TRUTH)
}

tasks.test {
Expand Down
20 changes: 20 additions & 0 deletions corellium/adapter/src/main/kotlin/flank/corellium/Api.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package flank.corellium

import flank.corellium.adapter.ExecuteAndroidTestPlan
import flank.corellium.adapter.InstallAndroidApps
import flank.corellium.adapter.InvokeAndroidDevices
import flank.corellium.adapter.RequestAuthorization
import flank.corellium.api.CorelliumApi

fun corelliumApi(
projectName: String
) = CorelliumApi(
authorize = RequestAuthorization,
installAndroidApps = InstallAndroidApps(
projectName = projectName
),
invokeAndroidDevices = InvokeAndroidDevices(
projectName = projectName
),
executeTest = ExecuteAndroidTestPlan
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package flank.corellium.adapter

import flank.corellium.client.core.Corellium

internal val corellium: Corellium
get() = requireNotNull(corelliumRef) {
"Corellium is not initialized, try to call connectCorellium at first."
}

// It's totally ok to keep corellium as singleton since we don't need handle more than one connection for single run.
internal var corelliumRef: Corellium? = null
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package flank.corellium.adapter

import flank.corellium.api.AndroidTestPlan
import flank.corellium.client.console.clear
import flank.corellium.client.console.flowLogs
import flank.corellium.client.console.sendCommand
import flank.corellium.client.console.waitForIdle
import flank.corellium.client.core.connectConsole
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch

object ExecuteAndroidTestPlan : AndroidTestPlan.Execute {
override suspend fun AndroidTestPlan.Config.invoke(): Flow<String> =
Copy link
Contributor

Choose a reason for hiding this comment

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

nice :)

coroutineScope { channelFlow { executeTests(instances, channel).joinAll() } }
}

private fun CoroutineScope.executeTests(
instances: Map<String, List<AndroidTestPlan.Shard>>,
channel: SendChannel<String>
): List<Job> =
instances.map { (instanceId, shards: List<AndroidTestPlan.Shard>) ->
launch {
println("Getting console $instanceId")
corellium.connectConsole(instanceId).apply {
clear()
launch {
shards.map { it.prepareRunCommand() }
.onEach { println("Sending command: $it") }
.forEach { sendCommand(it) }
}
launch { flowLogs().collect(channel::send) }
waitForIdle(10_000)
}
}
}

private fun AndroidTestPlan.Shard.prepareRunCommand(): String {
val testCases = testCases // example: listOf("class foo.Bar#baz")
.map { it.split(" ") } // example: listOf(listOf("class", "foo.Bar#baz"))
.groupBy({ it.first() }, { it.last() }) // example: first => "class", last => "foo.Bar#baz"
.toList().joinToString("") { (type, tests: List<String>) ->
"-e $type ${tests.joinToString(",")} " // example: "-e class foo.Bar#baz"
} // example: "-e class foo.Bar#baz1,foo.Bar#baz2 -e package foo.test "

val runner = "$packageName/$testRunner"

// example: "am instrument -r -w -e class foo.Bar#baz foo.test/androidx.test.runner.AndroidJUnitRunner"
return AM_INSTRUMENT + testCases + runner
}

private const val AM_INSTRUMENT = "am instrument -r -w "
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package flank.corellium.adapter

import flank.corellium.api.AndroidApps
import flank.corellium.client.agent.disconnect
import flank.corellium.client.agent.uploadFile
import flank.corellium.client.console.close
import flank.corellium.client.console.sendCommand
import flank.corellium.client.core.connectAgent
import flank.corellium.client.core.connectConsole
import flank.corellium.client.core.getAllProjects
import flank.corellium.client.core.getProjectInstancesList
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flowOf
import java.io.File

private const val PATH_TO_UPLOAD = "/sdcard"

class InstallAndroidApps(
private val projectName: String
) : AndroidApps.Install {

override suspend fun List<AndroidApps>.invoke(): Unit = corellium.run {
val projectId = getAllProjects().first { it.name == projectName }.id
val instances = getProjectInstancesList(projectId).associateBy { it.id }

forEach { apps ->
val instance = instances.getValue(apps.instanceId)

println("Connecting agent for ${apps.instanceId}")
val agentInfo = requireNotNull(instance.agent?.info) {
"Cannot connect to the agent, no agent info for instance ${instance.name} with id: ${instance.id}"
}
val agent = connectAgent(agentInfo)

println("Connecting console for ${apps.instanceId}")
val console = connectConsole(instance.id)

// Disable system logging
flowOf("su", "dmesg -n 1", "exit").collect(console::sendCommand)

apps.paths.forEach { localPath ->
val file = File(localPath)
val remotePath = "$PATH_TO_UPLOAD/${file.name}"

println("Uploading apk $localPath")
agent.uploadFile(remotePath, file.readBytes())

println("Installing apk $localPath")
console.sendCommand(
// Current solution is enough for the MVP.
// Fixme: Find better solution for recognizing test apk.
if (localPath.endsWith("androidTest.apk"))
"pm install -t $remotePath" else
"pm install $remotePath"
)
}

console.close()
agent.disconnect()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package flank.corellium.adapter

import flank.corellium.api.AndroidInstance
import flank.corellium.api.AndroidInstance.Config
import flank.corellium.client.core.createNewInstance
import flank.corellium.client.core.getAllProjects
import flank.corellium.client.core.getProjectInstancesList
import flank.corellium.client.core.startInstance
import flank.corellium.client.core.waitUntilInstanceIsReady
import flank.corellium.client.data.Instance
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch

private const val FLANK_INSTANCE_NAME_PREFIX = "flank-android-"
private const val FLAVOUR = "ranchu"
private const val OS = "11.0.0"
private const val SCREEN = "720x1280:280"

class InvokeAndroidDevices(
private val projectName: String,
) : AndroidInstance.Invoke {
override suspend fun Config.invoke(): List<String> = coroutineScope {
val projectId = getProjectId(projectName)
val instances = getCreatedInstances(projectId, amount)
startNotRunningInstances(instances)

val ids = instances.map(Instance::id) + let {
// When existing instances size match required amount
// there is not needs for creating more instances.
if (instances.size == amount) emptyList()
// Otherwise is required to create some additional instances
else createInstances(
projectId = projectId,
indexes = calculateAdditionalIndexes(
current = instances,
requiredAmount = amount
)
)
}

waitForInstances(ids)
ids
}
}

// Important!!!
// Try to keep the private methods not dependent on each other.
// Otherwise the implementation will go complicated.

/**
* @return The project id for given project name.
*/
private suspend fun getProjectId(name: String) =
corellium.getAllProjects().first { it.name == name }.id

/**
* Get all instances that was already created for flank.
* @return [List] of [Instance] where [List.size] <= [amount]
*/
private suspend fun getCreatedInstances(
projectId: String,
amount: Int
): List<Instance> = corellium
.also { println("Getting instances already created by flank.") }
.getProjectInstancesList(projectId)
.filter { it.name.startsWith(FLANK_INSTANCE_NAME_PREFIX) }
.filter { it.state !in Instance.State.unavailable }
.take(amount)
.apply { println("Obtained $size already created devices") }

/**
* Start all given instances with status different than "on".
*/
private suspend fun startNotRunningInstances(
instances: List<Instance>
): Unit = instances
.filter { it.state != Instance.State.ON }
.apply { if (isNotEmpty()) println("Starting not running $size instances.") }
.forEach { instance ->
corellium.startInstance(instance.id)
println(instance)
}

/**
* Create new instances basing on given indexes.
*/
private suspend fun createInstances(
projectId: String,
indexes: List<Int>
) = indexes
.apply { println("Creating additional ${indexes.size} instances. Connecting to the agents may take longer.") }
.map { index ->
corellium.createNewInstance(
Instance(
project = projectId,
name = FLANK_INSTANCE_NAME_PREFIX + index,
flavor = FLAVOUR,
os = OS,
bootOptions = Instance.BootOptions(
screen = SCREEN
)
)
)
}

/**
* Calculate the list indexes for additional instances to create.
*/
private fun calculateAdditionalIndexes(
current: List<Instance>,
requiredAmount: Int,
): List<Int> =
(0 until requiredAmount).toSet() // Create set of possible indexes starting from 0
.minus(current.map(Instance::index)) // Subtract already created indexes
.sorted()
.take(requiredAmount - current.size) // Skip the excess indexes

/**
* The index encoded in instance name.
* This will work only on devices created by flank.
*/
private val Instance.index get() = name.removePrefix(FLANK_INSTANCE_NAME_PREFIX).toInt()

/**
* Block the execution and wait until each instance change the status to "on".
*/
private suspend fun waitForInstances(ids: List<String>): Unit = coroutineScope {
println("Wait until all instances are ready...")
ids.map { id ->
launch {
corellium.waitUntilInstanceIsReady(id)
println("ready: $id")
}
}.joinAll()
println("All instances invoked and ready to use.")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package flank.corellium.adapter

import flank.corellium.api.Authorization
import flank.corellium.client.core.connectCorellium

object RequestAuthorization : Authorization.Request {
override suspend fun Authorization.Credentials.invoke() {
corelliumRef = connectCorellium(
api = host,
username = username,
password = password
)
}
}
Loading