Compare commits

..

No commits in common. "42ceff08ea5cc280a2aa04612b656ea121b06cc4" and "faf841824d5bb17a42a4dc456a89984f42b50ef5" have entirely different histories.

4 changed files with 161 additions and 66 deletions

View file

@ -1,6 +1,5 @@
package dev.lowrespalmtree.comet package dev.lowrespalmtree.comet
import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -19,8 +18,6 @@ class PreTextLine(val text: String) : Line
class BlockquoteLine(val text: String) : Line class BlockquoteLine(val text: String) : Line
class ListItemLine(val text: String) : Line class ListItemLine(val text: String) : Line
const val TAG = "Gemtext"
fun parseData( fun parseData(
inChannel: Channel<ByteArray>, inChannel: Channel<ByteArray>,
charset: Charset, charset: Charset,
@ -30,7 +27,6 @@ fun parseData(
scope.launch { scope.launch {
var isPref = false var isPref = false
var buffer = ByteArray(0) var buffer = ByteArray(0)
Log.d(TAG, "parseData: start getting data from channel")
for (data in inChannel) { for (data in inChannel) {
buffer += data buffer += data
var nextLineFeed: Int = -1 var nextLineFeed: Int = -1
@ -45,7 +41,6 @@ fun parseData(
channel.send(line) channel.send(line)
} }
} }
Log.d(TAG, "parseData: channel closed")
channel.close() channel.close()
} }
return channel return channel

View file

@ -11,12 +11,17 @@ import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import dev.lowrespalmtree.comet.databinding.ActivityMainBinding import dev.lowrespalmtree.comet.databinding.ActivityMainBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.*
import java.net.ConnectException
import java.net.UnknownHostException
import java.nio.charset.Charset
@ExperimentalCoroutinesApi
class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen { class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private lateinit var pageViewModel: PageViewModel private lateinit var pageViewModel: PageViewModel
@ -87,7 +92,10 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
} }
when (uri.scheme) { when (uri.scheme) {
"gemini" -> pageViewModel.sendGeminiRequest(uri) "gemini" -> {
currentUrl = uri.toString()
pageViewModel.sendGeminiRequest(uri)
}
else -> openUnknownScheme(uri) else -> openUnknownScheme(uri)
} }
} }
@ -117,27 +125,17 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
Log.d(TAG, "handleEvent: $event") Log.d(TAG, "handleEvent: $event")
if (!event.handled) { if (!event.handled) {
when (event) { when (event) {
is PageViewModel.SuccessEvent -> { is PageViewModel.SuccessEvent -> visitedUrls.add(event.uri)
currentUrl = event.uri is PageViewModel.RedirectEvent -> openUrl(event.uri, redirections = event.redirects)
visitedUrls.add(event.uri) is PageViewModel.FailureEvent -> alert(event.message)
}
is PageViewModel.RedirectEvent -> {
openUrl(event.uri, base = currentUrl, redirections = event.redirects)
}
is PageViewModel.FailureEvent -> {
var message = event.details
if (!event.serverDetails.isNullOrEmpty())
message += "\n\n" + "Server details: ${event.serverDetails}"
alert(message, title = event.short)
}
} }
event.handled = true event.handled = true
} }
} }
private fun alert(message: String, title: String? = null) { private fun alert(message: String) {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(title ?: getString(R.string.error_alert_title)) .setTitle(R.string.error_alert_title)
.setMessage(message) .setMessage(message)
.create() .create()
.show() .show()
@ -151,6 +149,121 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
} }
} }
class PageViewModel : ViewModel() {
private var requestJob: Job? = null
val state: MutableLiveData<State> by lazy { MutableLiveData<State>(State.IDLE) }
private var linesList = ArrayList<Line>()
val lines: MutableLiveData<List<Line>> by lazy { MutableLiveData<List<Line>>() }
val event: MutableLiveData<Event> by lazy { MutableLiveData<Event>() }
enum class State {
IDLE, CONNECTING, RECEIVING
}
abstract class Event(var handled: Boolean = false)
data class SuccessEvent(val uri: String) : Event()
data class RedirectEvent(val uri: String, val redirects: Int) : Event()
data class FailureEvent(val message: String) : Event()
/**
* Perform a request against this URI.
*
* The URI must be valid, absolute and with a gemini scheme.
*/
fun sendGeminiRequest(uri: Uri, redirects: Int = 0) {
Log.d(TAG, "sendRequest: URI \"$uri\"")
state.postValue(State.CONNECTING)
requestJob?.apply { if (isActive) cancel() }
requestJob = viewModelScope.launch(Dispatchers.IO) {
val response = try {
val request = Request(uri)
val socket = request.connect()
val channel = request.proceed(socket, this)
Response.from(channel, viewModelScope)
} catch (e: Exception) {
Log.e(TAG, "sendGeminiRequest coroutine: ${e.stackTraceToString()}")
// If we got cancelled, die silently.
if (!isActive)
return@launch
signalError(
when (e) {
is UnknownHostException -> "Unknown host \"${uri.authority}\"."
is ConnectException -> "Can't connect to this server: ${e.message}."
else -> "Oops, something failed!"
}
)
return@launch
}
if (!isActive)
return@launch
if (response == null) {
signalError("Can't parse server response.")
return@launch
}
Log.i(TAG, "sendRequest: got ${response.code} with meta \"${response.meta}\"")
when (response.code.getCategory()) {
Response.Code.Category.SUCCESS -> handleRequestSuccess(response, uri)
Response.Code.Category.REDIRECT -> handleRedirect(response, redirects = redirects + 1)
Response.Code.Category.SERVER_ERROR -> handleError(response)
else -> signalError("Can't handle code ${response.code}.")
}
}
}
private fun signalError(message: String) {
event.postValue(FailureEvent(message))
state.postValue(State.IDLE)
}
private suspend fun handleRequestSuccess(response: Response, uri: Uri) {
state.postValue(State.RECEIVING)
linesList.clear()
lines.postValue(linesList)
val charset = Charset.defaultCharset()
var mainTitle: String? = null
var lastUpdate = System.currentTimeMillis()
for (line in parseData(response.data, charset, viewModelScope)) {
linesList.add(line)
if (mainTitle == null && line is TitleLine && line.level == 1)
mainTitle = line.text
val time = System.currentTimeMillis()
if (time - lastUpdate >= 100) { // Throttle to 100ms the recycler view updates…
lines.postValue(linesList)
lastUpdate = time
}
}
lines.postValue(linesList)
// We record the history entry here: it's nice because we have the main title available
// and we're already in a coroutine for database access.
History.record(uri.toString(), mainTitle)
event.postValue(SuccessEvent(uri.toString()))
state.postValue(State.IDLE)
}
private fun handleRedirect(response: Response, redirects: Int) {
event.postValue(RedirectEvent(response.meta, redirects))
}
private fun handleError(response: Response) {
event.postValue(FailureEvent(when (response.code) {
Response.Code.TEMPORARY_FAILURE -> "40: the server encountered a temporary failure."
Response.Code.SERVER_UNAVAILABLE -> "41: the server is currently unavailable."
Response.Code.CGI_ERROR -> "42: a CGI script encountered an error."
Response.Code.PROXY_ERROR -> "43: the server failed to proxy the request."
Response.Code.SLOW_DOWN -> "44: slow down!"
Response.Code.PERMANENT_FAILURE -> "50: this request failed and similar requests will likely fail as well."
Response.Code.NOT_FOUND -> "51: this page can't be found."
Response.Code.GONE -> "52: this page is gone."
Response.Code.PROXY_REQUEST_REFUSED -> "53: the server refused to proxy the request."
Response.Code.BAD_REQUEST -> "59: bad request."
else -> "${response.code}: unknown error code."
}))
}
}
companion object { companion object {
const val TAG = "MainActivity" const val TAG = "MainActivity"
} }

View file

@ -7,7 +7,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.net.InetSocketAddress
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
@ -21,9 +20,8 @@ class Request(private val uri: Uri) {
Log.d(TAG, "connect") Log.d(TAG, "connect")
val context = SSLContext.getInstance("TLSv1.2") val context = SSLContext.getInstance("TLSv1.2")
context.init(null, arrayOf(TrustManager()), null) context.init(null, arrayOf(TrustManager()), null)
val socket = context.socketFactory.createSocket() as SSLSocket val socket = context.socketFactory.createSocket(uri.host, port) as SSLSocket
socket.soTimeout = 10000 socket.soTimeout = 10000
socket.connect(InetSocketAddress(uri.host, port), 10000)
socket.startHandshake() socket.startHandshake()
return socket return socket
} }
@ -41,11 +39,13 @@ class Request(private val uri: Uri) {
try { try {
@Suppress("BlockingMethodInNonBlockingContext") // what u gonna do @Suppress("BlockingMethodInNonBlockingContext") // what u gonna do
while ((bis.read(buffer).also { numRead = it }) >= 0) { while ((bis.read(buffer).also { numRead = it }) >= 0) {
Log.d(TAG, "proceed coroutine: received $numRead bytes")
val received = buffer.sliceArray(0 until numRead) val received = buffer.sliceArray(0 until numRead)
channel.send(received) channel.send(received)
} }
} catch (e: SocketTimeoutException) { } catch (e: SocketTimeoutException) {
Log.i(TAG, "proceed coroutine: socket timeout.") Log.i(TAG, "Socket timeout.")
channel.cancel()
} }
} }
} }
@ -56,10 +56,9 @@ class Request(private val uri: Uri) {
} }
@SuppressLint("CustomX509TrustManager") @SuppressLint("CustomX509TrustManager")
class TrustManager : X509TrustManager { class TrustManager: X509TrustManager {
@SuppressLint("TrustAllX509TrustManager") @SuppressLint("TrustAllX509TrustManager")
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) { override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
Log.d(TAG, "cool cert, please continue") Log.d(TAG, "cool cert, please continue")

View file

@ -3,7 +3,7 @@ package dev.lowrespalmtree.comet
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.charset.Charset import java.nio.charset.Charset
@ -51,57 +51,45 @@ class Response(val code: Code, val meta: String, val data: Channel<ByteArray>) {
companion object { companion object {
private const val TAG = "Response" private const val TAG = "Response"
private const val MAX_META_LEN = 1024
private const val MAX_HEADER_LEN = 2 + 1 + MAX_META_LEN + 2
/** Return a response object from the incoming server data, served through the channel. */ /** Return a response object from the incoming server data, served through the channel. */
suspend fun from(channel: Channel<ByteArray>, scope: CoroutineScope): Response? { suspend fun from(channel: Channel<ByteArray>, scope: CoroutineScope): Response? {
var received = 0 var received = 0
val headerBuffer = ByteBuffer.allocate(1024) val headerBuffer = ByteBuffer.allocate(1024)
var lfIndex: Int for (data in channel) {
// While we don't have a response object (i.e. no header parsed), keep reading.
while (true) {
val data = try {
channel.receive()
} catch (e: ClosedReceiveChannelException) {
Log.i(TAG, "companion from: channel closed during initial receive")
return null
}
if (received + data.size > MAX_HEADER_LEN) {
Log.i(TAG, "companion from: received too much data for a valid header")
return null
}
// Push some data into our buffer. // Push some data into our buffer.
headerBuffer.put(data) headerBuffer.put(data)
received += data.size received += data.size
// Check if there is enough data to parse a Gemini header from it (e.g. has \r\n). // Check if there is enough data to parse a Gemini header from it (e.g. has \r\n).
lfIndex = headerBuffer.array().indexOf(0x0D) // \r val lfIndex = headerBuffer.array().indexOf(0x0D) // \r
if (lfIndex == -1) if (lfIndex == -1)
continue continue
if (headerBuffer.array()[lfIndex + 1] != (0x0A.toByte())) // \n if (headerBuffer.array()[lfIndex + 1] != (0x0A.toByte())) // \n
continue continue
break
}
// We have our header! Parse it to create our Response object. // We have our header! Parse it to create our Response object.
val bytes = headerBuffer.array() val bytes = headerBuffer.array()
val headerData = bytes.sliceArray(0 until lfIndex) val headerData = bytes.sliceArray(0 until lfIndex)
val (code, meta) = parseHeader(headerData) val (code, meta) = parseHeader(headerData)
?: return null .also { Log.i(TAG, "companion from: can't parse header") } ?: return null.also { Log.e(TAG, "Failed to parse header") }
val response = Response(code, meta, Channel()) val responseChannel = Channel<ByteArray>()
val response = Response(code, meta, responseChannel)
scope.launch { scope.launch {
// If we got too much data from the channel: push the trailing data first. // If we got too much data from the channel: push the trailing data first.
val trailingIndex = lfIndex + 2 val trailingIndex = lfIndex + 2
if (trailingIndex < received) { if (trailingIndex < received) {
val trailingData = bytes.sliceArray(trailingIndex until received) val trailingData = bytes.sliceArray(trailingIndex until received)
response.data.send(trailingData) responseChannel.send(trailingData)
} }
// Forward all incoming data to the Response channel. // Forward all incoming data to the Response channel.
for (data in channel) channel.consumeEach { responseChannel.send(it) }
response.data.send(data) responseChannel.close()
response.data.close()
} }
// 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 response
} }
return null
}
/** Return the code and meta from this header if it could be parsed correctly. */ /** Return the code and meta from this header if it could be parsed correctly. */
private fun parseHeader(data: ByteArray): Pair<Code, String>? { private fun parseHeader(data: ByteArray): Pair<Code, String>? {