【Android 】利用Ktor建立FileServer

最近有個需求,需要利用WebView來播放本地端的檔案,看似簡單的需求,但卻會被Chromium給擋掉,原因是因為Chromium不允許直接讀取本地檔案。為了解決這件事情,讓WebView可以順利讀取到本地檔案,那就直接在App裏開啟一個HttpServer讓WebView透過HttpServer來讀取檔案。

找了很多套件,試了很多方式,最後才跟Ktor相見恨晚啊!Ktor是什麼呢?這是一個專門為Kotlin打造的網路相關套件,你可以透過Ktor來建立Server端或是Client端的程式。而且真的算是蠻簡單的,官方也提供了範例程式,基本上只需要小改一下就可以直些使用了。

引入套件

// Ktor的核心包
implementation "io.ktor:ktor-server-core:2.0.1"
// 供Ktor使用的引擎包,另外有Jetty, Tomcat, CIO可用
implementation "io.ktor:ktor-server-netty:2.0.1"
// 用於印出Request及Response的log用
implementation "io.ktor:ktor-server-call-logging:2.0.1"
// 用於支援PartialContent用
implementation "io.ktor:ktor-server-partial-content:2.0.1"
// 用於支援CORS用
implementation "io.ktor:ktor-server-cors:2.0.1"
// 用於回傳客製html用
implementation "io.ktor:ktor-server-html-builder:2.0.1"
build.gradle

實作

根據官方的範例,透過 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)

至此,完工

資料來源