【Android 】利用Ktor建立FileServer
最近有個需求,需要利用WebView來播放本地端的檔案,看似簡單的需求,但卻會被Chromium給擋掉,原因是因為Chromium不允許直接讀取本地檔案。為了解決這件事情,讓WebView可以順利讀取到本地檔案,那就直接在App裏開啟一個HttpServer讓WebView透過HttpServer來讀取檔案。
找了很多套件,試了很多方式,最後才跟Ktor相見恨晚啊!Ktor是什麼呢?這是一個專門為Kotlin打造的網路相關套件,你可以透過Ktor來建立Server端或是Client端的程式。而且真的算是蠻簡單的,官方也提供了範例程式,基本上只需要小改一下就可以直些使用了。
引入套件
實作
根據官方的範例,透過 embeddedServer
就可以啟動這個HttpServer了。
embeddedServer(Netty, port = 8080) {
routing {
get("/") {
call.respondText("Hello, world!")
}
}
}.start(wait = true)
CallLogging
現在我們來把功能一樣一樣的加進去,首先先來完成Logger的功能。這邊宣告了requestHeaders跟responseHeaders來放Request及Response的Header部分,接著透過 toMap()
將Headers轉成map型態,再利用 toSortedMap()
進行排序後,最後以 joinToString()
將其轉換為文字。後面就只是把要印出來的資訊整理整理。
embeddedServer(Netty, port = 8080) {
install(CallLogging) {
format { call ->
val requestHeaders = call.request.headers.toMap().toSortedMap().entries.joinToString("\n ")
val responseHeaders = call.response.headers.allValues().toMap().toSortedMap().entries.joinToString("\n ")
"\n=========================[Request]=========================" +
"\n[${call.request.httpMethod.value}] ${call.request.uri}${call.request.queryString().let { if(it.isNotEmpty()) "?$it" else "" }}" +
"\nRemoteHost: ${call.request.host()}" +
"\nHeaders: \n $requestHeaders" +
"\n=========================[Response]=========================" +
"\nStatus: ${call.response.status()}" +
"\nHeaders: \n $responseHeaders" +
"\n==========================================================="
}
}
routing {
get("/") {
call.respondText("Hello, world!")
}
}
}.start(wait = true)
PartialContent
而PartialContent的部分就比較簡單了,透過 install(PartialContent)
加進去就可以了
embeddedServer(Netty, port = 8080) {
install(PartialContent)
routing {
get("/") {
call.respondText("Hello, world!")
}
}
}.start(wait = true)
CORS
在CORS的部分,可以在這裡設定要允許的HttpMethod、Header及Host
embeddedServer(Netty, port = 8080) {
install(CORS) {
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Options)
allowHeader("X-Requested-With")
anyHost()
maxAgeInSeconds = 3628800
}
routing {
get("/") {
call.respondText("Hello, world!")
}
}
}.start(wait = true)
FileServer
下面這段直接拷貝官方範例,但大致上就是把檔案清單列出來然後透過html builder來建立Html頁面並回傳。
private fun Route.listing(folder: File) {
val dir = staticRootFolder.combine(folder)
val pathParameterName = "static-content-path-parameter"
val dateFormat = SimpleDateFormat("dd-MMM-yyyy HH:mm", Locale.getDefault())
get("{$pathParameterName...}") {
val relativePath = call.parameters.getAll(pathParameterName)?.joinToString(File.separator) ?: return@get
val file = dir.combineSafe(relativePath)
if (file.isDirectory) {
val isRoot = relativePath.trim('/').isEmpty()
val files = file.listSuspend(includeParent = !isRoot)
val base = call.request.path().trimEnd('/')
call.respondHtml {
body {
h1 { +"Index of $base/" }
hr {}
table {
style = "width: 100%;"
thead {
tr {
for (column in listOf("Name", "Last Modified", "Size", "MimeType")) {
th {
style = "width: 25%; text-align: left;"
+column
}
}
}
}
tbody {
for (fInfo in files) {
val rName = if (fInfo.directory) "${fInfo.name}/" else fInfo.name
tr {
td {
if (fInfo.name == "..") {
a(File(base).parent) { +rName }
} else {
a("$base/$rName") { +rName }
}
}
td { +dateFormat.format(fInfo.date) }
td { +(if (fInfo.directory) "-" else "${fInfo.size}") }
td { +(ContentType.fromFilePath(fInfo.name).firstOrNull()?.toString() ?: "-") }
}
}
}
}
hr {}
}
}
}
}
}
private fun File?.combine(file: File) = when {
this == null -> file
else -> resolve(file)
}
private suspend fun File.listSuspend(includeParent: Boolean = false): List<FileInfo> {
val file = this
return withContext(Dispatchers.IO) {
listOfNotNull(if (includeParent) FileInfo("..", Date(), true, 0L) else null) + file.listFiles().toList().map {
FileInfo(it.name, Date(it.lastModified()), it.isDirectory, it.length())
}.sortedWith(
comparators(
Comparator { a, b -> -a.directory.compareTo(b.directory) },
Comparator { a, b -> a.name.compareTo(b.name, ignoreCase = true) }
)
)
}
}
data class FileInfo(val name: String, val date: Date, val directory: Boolean, val size: Long)
private fun <T> comparators(vararg comparators: Comparator<T>): Comparator<T> {
return Comparator { l, r ->
for (comparator in comparators) {
val result = comparator.compare(l, r)
if (result != 0) return@Comparator result
}
return@Comparator 0
}
}
operator fun <T> Comparator<T>.plus(other: Comparator<T>): Comparator<T> = comparators(this, other)
Routing
接下來就可以完成Routing的部分,這裡的root是一個File型態,位置是要讓外部瀏覽的資料夾位置
embeddedServer(Netty, port = 8080) {
install(PartialContent)
routing {
get("/") {
call.respondRedirect("/files")
}
route("/files") {
files(root)
listing(root)
}
}
}.start(wait = true)
MixContent
當在https的網頁裡叫用http的資源時,會出現MixContent的錯誤警告,所以接下來我們要把SSL打開,讓HttpServer支援https。
首先,先利用keyTool來建立一把keyStore,這邊要將alias及密碼給記起來,後面會用到
keytool -genkey -keyalg RSA -alias alias -keystore keystore.bks -storepass storepass -validity 3650 -keysize 2048 -ext SAN=DNS:localhost,IP:127.0.0.1 -validity 9999
但由於這樣建立出來的keyStore並不被Android所支援,所以要透過第三方的軟體 KeyStore Explorer 來幫忙改變KeyStore的格式,利用KeyStore Explorer將剛剛產出的keyStore打開後,點選上方的Tools--->change KeyStore Type--->BKS-V1,這樣就可以把格式改成Android可以支援的BKS-V1
接著把處理好的keyStore檔案,放進專案裡res資料夾下的raw,這邊我將檔案命名為 keystore.bks
回到程式裡,我們先把 keystore.bks
檔案取出
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
.apply{
load(
context.resources.openRawResource(R.raw.keystore),
"storepass".toCharArray()
)
}
接著把keyStore的資訊在ktor裡設置
embeddedServer(Netty, environment = applicationEngineEnvironment {
connector {
host = getIPAddress()
port = PORT_NUM
}
sslConnector(
keyStore = keyStore,
keyAlias = "alias",
keyStorePassword = { "storepass".toCharArray() },
privateKeyPassword = { "storepass".toCharArray() }
) {
host = getIPAddress()
port = SSL_PORT_NUM
}
}).start(wait = true)
這裡多寫了一個 getIPAddress()
來取得當前Server的IP位置
private fun getIpAddress(): String {
var ip = "127.0.0.1"
try {
val enumNetworkInterfaces = NetworkInterface.getNetworkInterfaces()
while (enumNetworkInterfaces.hasMoreElements()) {
val networkInterface = enumNetworkInterfaces.nextElement()
val enumInetAddress = networkInterface.inetAddresses
while (enumInetAddress.hasMoreElements()) {
val inetAddress = enumInetAddress.nextElement()
if (inetAddress.isSiteLocalAddress) ip = inetAddress.hostAddress
}
}
} catch (e: SocketException) {
e.printStackTrace()
}
return ip
}
所以最後你的embeddedServer應該長得像這樣
embeddedServer(Netty, environment = applicationEngineEnvironment {
connector {
host = IP
port = PORT_NUM
}
sslConnector(
keyStore = keyStore,
keyAlias = "alias",
keyStorePassword = { "storepass".toCharArray() },
privateKeyPassword = { "storepass".toCharArray() }
) {
host = IP
port = SSL_PORT_NUM
}
module {
install(CallLogging) {
format { call ->
val requestHeaders = call.request.headers.toMap().toSortedMap().entries.joinToString("\n ")
val responseHeaders = call.response.headers.allValues().toMap().toSortedMap().entries.joinToString("\n ")
"\n=========================[Request]=========================" +
"\n[${call.request.httpMethod.value}] ${call.request.uri}${call.request.queryString().let { if(it.isNotEmpty()) "?$it" else "" }}" +
"\nRemoteHost: ${call.request.host()}" +
"\nHeaders: \n $requestHeaders" +
"\n=========================[Response]=========================" +
"\nStatus: ${call.response.status()}" +
"\nHeaders: \n $responseHeaders" +
"\n==========================================================="
}
}
install(PartialContent)
install(CORS) {
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Options)
allowHeader("X-Requested-With")
anyHost()
maxAgeInSeconds = 3628800
}
routing {
get("/") {
call.respondRedirect("/files")
}
route("/files") {
files(root)
listing(root)
}
}
}
}).start(wait = true)
至此,完工
資料來源