From 8e161dbb31062a3bbf9e00eed5a888c98e8dfd13 Mon Sep 17 00:00:00 2001 From: dece Date: Sun, 5 Dec 2021 20:03:52 +0100 Subject: [PATCH] parse response headers --- ARCHITECTURE.md | 26 ++++++ .../dev/lowrespalmtree/comet/MainActivity.kt | 27 ++++-- .../java/dev/lowrespalmtree/comet/Request.kt | 1 + .../java/dev/lowrespalmtree/comet/Response.kt | 91 +++++++++++++++++++ app/src/main/res/values/strings.xml | 2 +- 5 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 app/src/main/java/dev/lowrespalmtree/comet/Response.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..d6c1757 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,26 @@ +Architecture +============ + + + +Streaming +--------- + +Just because I'm frustrated by how hard it is to implement streaming on Bebop +the way I've built it so far, properly streaming responses is a core focus of +Comet, even though I barely encounter streamed pages on Gemini. + +This is the streaming process I want to do, not sure if it makes sense. + +- `connect/proceed` block and read from the server. +- Gemtext parts +- Views are created from Gemtext parts (or a single TextView for text/* files). + We need some kind of producer/consumer here. +- The page view is a vertical LinearLayout (should we use a cursed Recycler?). + New views should be passed to the activity and added at the end of the + layout (does it work without blinking or other issues?). + +The data is received through a buffered SSLSocket object. + +1. Receive data +2. If we can parse a header, do it. diff --git a/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt b/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt index ca5b8c7..4a1ff3c 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt @@ -66,20 +66,35 @@ class MainActivity : AppCompatActivity() { viewModelScope.launch(Dispatchers.IO) { val uri = Uri.parse(url) if (uri.scheme != "gemini") { - alertLiveData.postValue("Unknown scheme.") + alertLiveData.postValue("Can't process scheme \"${uri.scheme}\".") return@launch } + val request = Request(uri) val socket = request.connect() val channel = request.proceed(socket, this) - val charset = Charset.defaultCharset() - for (data in channel) { - val decoded = charset.decode(ByteBuffer.wrap(data)).toString() - source += decoded - sourceLiveData.postValue(source) + val response = Response.from(channel, viewModelScope) + if (response == null) { + alertLiveData.postValue("Can't parse server response.") + return@launch + } + + Log.i(TAG, "sendRequest: got ${response.code} with meta \"${response.meta}\"") + when (response.code) { + Response.Code.SUCCESS -> handleRequestSuccess(response) + else -> alertLiveData.postValue("Can't handle code ${response.code}.") } } } + + private suspend fun handleRequestSuccess(response: Response) { + val charset = Charset.defaultCharset() + for (data in response.data) { + val decoded = charset.decode(ByteBuffer.wrap(data)).toString() + source += decoded + sourceLiveData.postValue(source) + } + } } companion object { diff --git a/app/src/main/java/dev/lowrespalmtree/comet/Request.kt b/app/src/main/java/dev/lowrespalmtree/comet/Request.kt index b327cda..2b6885d 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/Request.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/Request.kt @@ -37,6 +37,7 @@ class Request(private val uri: Uri) { socket.inputStream.use { socket_input_stream -> BufferedInputStream(socket_input_stream).use { bis -> try { + @Suppress("BlockingMethodInNonBlockingContext") // what u gonna do while ((bis.read(buffer).also { numRead = it }) >= 0) { Log.d(TAG, "proceed coroutine: received $numRead bytes") val received = buffer.sliceArray(0 until numRead) diff --git a/app/src/main/java/dev/lowrespalmtree/comet/Response.kt b/app/src/main/java/dev/lowrespalmtree/comet/Response.kt new file mode 100644 index 0000000..4e9b8b9 --- /dev/null +++ b/app/src/main/java/dev/lowrespalmtree/comet/Response.kt @@ -0,0 +1,91 @@ +package dev.lowrespalmtree.comet + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.launch +import java.nio.ByteBuffer +import java.nio.charset.Charset + +class Response(val code: Code, val meta: String, val data: Channel) { + @Suppress("unused") + enum class Code(val value: Int) { + UNKNOWN(0), + INPUT(10), + SENSITIVE_INPUT(11), + SUCCESS(20), + REDIRECT_TEMPORARY(30), + REDIRECT_PERMANENT(31), + TEMPORARY_FAILURE(40), + SERVER_UNAVAILABLE(41), + CGI_ERROR(42), + PROXY_ERROR(43), + SLOW_DOWN(44), + PERMANENT_FAILURE(50), + NOT_FOUND(51), + GONE(52), + PROXY_REQUEST_REFUSED(53), + BAD_REQUEST(59), + CLIENT_CERTIFICATE_REQUIRED(60), + CERTIFICATE_NOT_AUTHORISED(61), + CERTIFICATE_NOT_VALID(62); + + companion object { + private val MAP = values().associateBy(Code::value) + fun fromInt(type: Int) = MAP[type] + } + } + + companion object { + private const val TAG = "Response" + + /** Return a response object from the incoming server data, served through the channel. */ + suspend fun from(channel: Channel, scope: CoroutineScope): Response? { + var received = 0 + val headerBuffer = ByteBuffer.allocate(1024) + for (data in channel) { + // Push some data into our buffer. + headerBuffer.put(data) + received += data.size + // Check if there is enough data to parse a Gemini header from it (e.g. has \r\n). + val lfIndex = headerBuffer.array().indexOf(0x0D) // \r + if (lfIndex == -1) + continue + if (headerBuffer.array()[lfIndex + 1] != (0x0A.toByte())) // \n + continue + // We have our header! Parse it to create our Response object. + val bytes = headerBuffer.array() + val headerData = bytes.sliceArray(0 until lfIndex) + val (code, meta) = parseHeader(headerData) + ?: return null.also { Log.e(TAG, "Failed to parse header") } + val responseChannel = Channel() + val response = Response(code, meta, responseChannel) + scope.launch { + // If we got too much data from the channel: push the trailing data first. + val trailingIndex = lfIndex + 2 + if (trailingIndex < received) { + val trailingData = bytes.sliceArray(trailingIndex until received) + responseChannel.send(trailingData) + } + // Forward all incoming data to the Response channel. + channel.consumeEach { responseChannel.send(it) } + } + // Return the response here; this stops consuming the channel from this for-loop so + // that the coroutine above can take care of it. + return response + } + return null + } + + /** Return the code and meta from this header if it could be parsed correctly. */ + private fun parseHeader(data: ByteArray): Pair? { + val string = data.toString(Charset.defaultCharset()) + val parts = string.split(" ", limit = 2) + if (parts.size != 2) + return null + val code = parts[0].toIntOrNull()?.let { Code.fromInt(it) } ?: return null + return Pair(code, parts[1]) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cd43c03..15a0eeb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ Comet - + Alert Settings URL \ No newline at end of file