package com.ustadmobile.test.http
import com.ustadmobile.lib.util.SysPathUtil
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import io.ktor.server.plugins.callloging.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import java.io.File
import java.io.FileFilter
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
const val ADB_RECORD_PARAM = "adbRecord"
const val DEVICE_SERIAL_PARAM = "device"
const val TESTNAME_PARAM = "testName"
const val TEST_FILE_NAME_PARAM = "test-file-name"
const val DEST_PARAM = "dest"
@Suppress("BlockingMethodInNonBlockingContext", "unused", "SdCardPath")
fun Application.testServerController() {
var adbRecordProcess: Process? = null
val adbPath = SysPathUtil.findCommandInPath(
commandName = "adb",
extraSearchPaths = System.getenv("ANDROID_HOME") ?: "",
)
var adbVideoName: String? = null
var currentSerial: String? = null
val resultDir = environment.config.propertyOrNull("resultDir")?.getString()?.let {
File(it)
} ?: File(".")
val serverSiteUrl = environment.config.property("siteUrl").getString()
if(adbPath == null || !adbPath.exists()) {
throw IllegalStateException("ERROR: ADB path does not exist")
}
fun adbPullFile(
deviceSerial: String,
fromPath: String,
destFile: File,
deleteAfter: Boolean = false
) {
destFile.parentFile.takeIf { !it.exists() }?.mkdirs()
log.info("Pulling file from device $deviceSerial $fromPath -> ${destFile.absolutePath}")
ProcessBuilder(listOf(adbPath.absolutePath, "-s", deviceSerial, "pull",
fromPath, destFile.absolutePath))
.start()
.also {
it.waitFor(20, TimeUnit.SECONDS)
}
if(deleteAfter) {
log.info("Delete $fromPath from $deviceSerial")
ProcessBuilder(listOf(adbPath.absolutePath, "-s", deviceSerial, "shell", "rm", fromPath))
.start()
.also {
it.waitFor(20, TimeUnit.SECONDS)
}
}
}
fun stopRecording() {
if(adbRecordProcess != null) {
ProcessBuilder(listOf(adbPath.absolutePath, "-s", (currentSerial ?: "err"), "shell", "kill",
"-SIGINT", "$(pidof screenrecord)"))
.start()
.also {
it.waitFor(20, TimeUnit.SECONDS)
}
adbRecordProcess?.waitFor(20, TimeUnit.SECONDS)
val destFile = File(File(resultDir, adbVideoName ?: "err"),
"screenrecord.mp4")
adbPullFile(currentSerial ?: "err", "/sdcard/$adbVideoName.mp4",
destFile, true)
adbRecordProcess = null
}
}
val serverDir = File("app-ktor-server")
val testFilesDir = File("test-end-to-end", "test-files")
val testContentDir = File(testFilesDir, "content")
log.info("TEST FILES: ${testContentDir.absolutePath}")
if(!serverDir.exists()) {
println("ERROR: Server dir does not exist! testServerManager working directory MUST be the " +
"root directory of the source code")
throw IllegalStateException("ERROR: Server dir does not exist! testServerManager working directory MUST be the " +
"root directory of the source code")
}
var serverProcess: Process? = null
Runtime.getRuntime().addShutdownHook(Thread {
stopRecording()
serverProcess?.destroy()
})
install(CORS) {
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Options)
allowHeader(HttpHeaders.ContentType)
anyHost()
}
install(CallLogging)
install(ContentNegotiation) {
json()
}
install(Routing) {
static("/test-files/content/") {
//KTOR default files implementation does not cooperate.
staticRootFolder = testContentDir
testContentDir.listFiles(FileFilter {
it.isFile
})?.forEach {
file(it.name)
}
default("index.html")
}
get("/") {
var response = ""
if(serverProcess != null) {
response = "Running pid #${serverProcess?.pid()} (running=${serverProcess?.isAlive}
"
}else {
response = "Server not running
"
}
call.response.header("cache-control", "no-cache")
call.respondText(
text = "
" +
"$response
" +
"Start or restart server now" +
"",
contentType = ContentType.Text.Html,
)
}
/**
* Start the test server and Android ADB screen recording as needed.
*
* API usage:
*
* GET start?recordAdbDevice=&testName=
*
* Params:
* recordAdbDevice: the serial of the device to record (as per adb devices command)
* testName: name of the test about to start - used to determine the directory to save video output
*/
get("/start") {
val requestDeviceSerial = call.request.queryParameters[DEVICE_SERIAL_PARAM] ?: ""
val adbRecordEnabled = call.request.queryParameters[ADB_RECORD_PARAM]?.toBoolean() ?: false
val config = call.application.environment.config
val clearPgJdbcUrl = config.propertyOrNull("ktor.testServer.clearPgUrl")?.getString()
val clearPgUser = config.propertyOrNull("ktor.testServer.clearPgUser")?.getString()
val clearPgPass = config.propertyOrNull("ktor.testServer.clearPgPass")?.getString()
var response = SimpleDateFormat.getDateTimeInstance().format(Date()) + "
"
serverProcess?.also {
it.destroy()
it.waitFor(5, TimeUnit.SECONDS)
response += "Stopped server: pid #${serverProcess?.pid()}
"
serverProcess = null
}
adbRecordProcess?.also {
stopRecording()
adbRecordProcess = null
}
if(clearPgJdbcUrl != null && clearPgUser != null && clearPgPass != null) {
clearPostgresDb(clearPgJdbcUrl, clearPgUser, clearPgPass)
}
currentSerial = requestDeviceSerial
adbVideoName = call.request.queryParameters[TESTNAME_PARAM]
?: System.currentTimeMillis().toString()
val dataDir = File(serverDir, "data")
if(dataDir.exists()){
dataDir.deleteRecursively()
response += "Cleared data directory: ${dataDir.absolutePath}
"
}
val serverArgs = call.application.environment.config
.propertyOrNull("ktor.testServer.command")?.getString()?.split(Regex("\\s+"))
?.toMutableList()
?: throw IllegalArgumentException("No testServer command specified in configuration")
//If the command is not an absolute path or relative path, then look in the PATH variable
if(!(serverArgs[0].startsWith(".") || serverArgs[0].startsWith("/"))) {
serverArgs[0] = SysPathUtil.findCommandInPath(serverArgs[0])?.absolutePath
?: throw IllegalArgumentException("Could not find server command in PATH ${serverArgs[0]}")
}
val serverArgsWithSiteUrl = serverArgs + "-P:ktor.ustad.siteUrl=$serverSiteUrl"
serverProcess = ProcessBuilder(serverArgsWithSiteUrl)
.directory(serverDir)
.redirectOutput(ProcessBuilder.Redirect.PIPE)
.redirectError(ProcessBuilder.Redirect.PIPE)
.start()
response += "Started server process PID #${serverProcess?.pid()} " +
"${serverArgsWithSiteUrl.joinToString( " ")} " +
"(workingDir=${serverDir.absolutePath}
"
if(adbRecordEnabled) {
ProcessBuilder(
listOf(adbPath.absolutePath, "-s", requestDeviceSerial, "shell",
"screencap", "/sdcard/$adbVideoName.png")
).start().waitFor(5, TimeUnit.SECONDS)
val screenshotDestFile = File(File(resultDir, adbVideoName ?: "err"),
"screenrecord-poster.png")
adbPullFile(requestDeviceSerial, "/sdcard/$adbVideoName.png",
screenshotDestFile, deleteAfter = true)
val recordArgs = listOf(adbPath.absolutePath, "-s", requestDeviceSerial,
"shell", "screenrecord", "/sdcard/$adbVideoName.mp4")
adbRecordProcess = ProcessBuilder(recordArgs)
.redirectOutput(ProcessBuilder.Redirect.PIPE)
.redirectError(ProcessBuilder.Redirect.PIPE)
.start()
response += "Started video recording: ${recordArgs.joinToString(separator = " ")} " +
"PID ${adbRecordProcess?.pid()}
"
application.log.info("Started video recording: ${recordArgs.joinToString(separator = " ")} " +
"PID ${adbRecordProcess?.pid()}")
}
call.response.header("cache-control", "no-cache")
call.respondText(
text = "$response",
contentType = ContentType.Text.Html
)
}
/**
* This is called by the stop.sh script just before the server gets shut down so we can
* properly save things as needed. Using the shutdown hook does not seem to allow video to
* finish properly
*/
get("/stop") {
serverProcess?.also {
it.destroy()
it.waitFor()
}
stopRecording()
call.response.header("cache-control", "no-cache")
call.respond(HttpStatusCode.OK, "OK")
}
/**
* Clear the Downloads directory of the device (to avoid running out of space and make
* sure that the uploaded content for a given test is visible at the top of the list).
*
* /cleardownloads?device=
*/
get("/cleardownloads") {
val deviceSerial = call.request.queryParameters[DEVICE_SERIAL_PARAM]
val adbCommand = SysPathUtil.findCommandInPath("adb")
?: throw IllegalStateException("Cannot find adb in path")
val process = ProcessBuilder(listOf(adbCommand.absolutePath,
"-s", deviceSerial, "shell", "rm", "/sdcard/Download/*"))
.redirectOutput(ProcessBuilder.Redirect.PIPE)
.redirectError(ProcessBuilder.Redirect.PIPE)
.start()
process.waitFor(5, TimeUnit.SECONDS)
call.response.header("cache-control", "no-cache")
call.respondText(
text = "Cleared download directory /sdcard/Download",
contentType = ContentType.Text.Plain,
)
}
/**
* Push file from the test content directory to the device Downloads directory using adb
*
* /pushcontent?device=&test-file-name=file-name.ext&dest=/sdcard/Pictures
*
* dest parameter is optional. The argument MUST be url encoded.
*
* test-file-name should be the name of a file found in the test files directory (
* test-end-to-end/test-files/content )
*/
get("/pushcontent") {
val deviceSerial = call.request.queryParameters[DEVICE_SERIAL_PARAM]
val fileName = call.request.queryParameters[TEST_FILE_NAME_PARAM]
?: throw IllegalArgumentException("No filename specified")
val pushDest = call.request.queryParameters[DEST_PARAM] ?: "/sdcard/Download"
val contentFile = File(testContentDir, fileName)
val adbCommand = SysPathUtil.findCommandInPath("adb")
?: throw IllegalStateException("Cannot find adb in path")
call.response.header("cache-control", "no-cache")
if(!contentFile.exists()) {
call.respondText(
status = HttpStatusCode.NotFound,
text = "No such file: $contentFile",
contentType = ContentType.Text.Plain,
)
return@get
}
val process = ProcessBuilder(listOf(adbCommand.absolutePath,
"-s", deviceSerial, "push", contentFile.absolutePath, pushDest))
.directory(serverDir)
.redirectOutput(ProcessBuilder.Redirect.PIPE)
.redirectError(ProcessBuilder.Redirect.PIPE)
.start()
process.waitFor(5, TimeUnit.SECONDS)
call.respondText(
text = "Pushed content to $deviceSerial ${contentFile.absolutePath} -> /sdcard/Download",
contentType = ContentType.Text.Plain
)
}
}
}