Compare commits
2 commits
faf841824d
...
42ceff08ea
Author | SHA1 | Date | |
---|---|---|---|
dece | 42ceff08ea | ||
dece | 21771bc45c |
|
@ -1,5 +1,6 @@
|
|||
package dev.lowrespalmtree.comet
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -18,6 +19,8 @@ class PreTextLine(val text: String) : Line
|
|||
class BlockquoteLine(val text: String) : Line
|
||||
class ListItemLine(val text: String) : Line
|
||||
|
||||
const val TAG = "Gemtext"
|
||||
|
||||
fun parseData(
|
||||
inChannel: Channel<ByteArray>,
|
||||
charset: Charset,
|
||||
|
@ -27,6 +30,7 @@ fun parseData(
|
|||
scope.launch {
|
||||
var isPref = false
|
||||
var buffer = ByteArray(0)
|
||||
Log.d(TAG, "parseData: start getting data from channel")
|
||||
for (data in inChannel) {
|
||||
buffer += data
|
||||
var nextLineFeed: Int = -1
|
||||
|
@ -41,6 +45,7 @@ fun parseData(
|
|||
channel.send(line)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "parseData: channel closed")
|
||||
channel.close()
|
||||
}
|
||||
return channel
|
||||
|
|
|
@ -11,17 +11,12 @@ import android.view.inputmethod.EditorInfo
|
|||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import dev.lowrespalmtree.comet.databinding.ActivityMainBinding
|
||||
import kotlinx.coroutines.*
|
||||
import java.net.ConnectException
|
||||
import java.net.UnknownHostException
|
||||
import java.nio.charset.Charset
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var pageViewModel: PageViewModel
|
||||
|
@ -92,10 +87,7 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
|
|||
}
|
||||
|
||||
when (uri.scheme) {
|
||||
"gemini" -> {
|
||||
currentUrl = uri.toString()
|
||||
pageViewModel.sendGeminiRequest(uri)
|
||||
}
|
||||
"gemini" -> pageViewModel.sendGeminiRequest(uri)
|
||||
else -> openUnknownScheme(uri)
|
||||
}
|
||||
}
|
||||
|
@ -125,17 +117,27 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
|
|||
Log.d(TAG, "handleEvent: $event")
|
||||
if (!event.handled) {
|
||||
when (event) {
|
||||
is PageViewModel.SuccessEvent -> visitedUrls.add(event.uri)
|
||||
is PageViewModel.RedirectEvent -> openUrl(event.uri, redirections = event.redirects)
|
||||
is PageViewModel.FailureEvent -> alert(event.message)
|
||||
is PageViewModel.SuccessEvent -> {
|
||||
currentUrl = event.uri
|
||||
visitedUrls.add(event.uri)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private fun alert(message: String) {
|
||||
private fun alert(message: String, title: String? = null) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.error_alert_title)
|
||||
.setTitle(title ?: getString(R.string.error_alert_title))
|
||||
.setMessage(message)
|
||||
.create()
|
||||
.show()
|
||||
|
@ -149,121 +151,6 @@ 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 {
|
||||
const val TAG = "MainActivity"
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.BufferedInputStream
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.SocketTimeoutException
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
|
@ -20,8 +21,9 @@ class Request(private val uri: Uri) {
|
|||
Log.d(TAG, "connect")
|
||||
val context = SSLContext.getInstance("TLSv1.2")
|
||||
context.init(null, arrayOf(TrustManager()), null)
|
||||
val socket = context.socketFactory.createSocket(uri.host, port) as SSLSocket
|
||||
val socket = context.socketFactory.createSocket() as SSLSocket
|
||||
socket.soTimeout = 10000
|
||||
socket.connect(InetSocketAddress(uri.host, port), 10000)
|
||||
socket.startHandshake()
|
||||
return socket
|
||||
}
|
||||
|
@ -39,13 +41,11 @@ class Request(private val uri: Uri) {
|
|||
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)
|
||||
channel.send(received)
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Log.i(TAG, "Socket timeout.")
|
||||
channel.cancel()
|
||||
Log.i(TAG, "proceed coroutine: socket timeout.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -56,9 +56,10 @@ class Request(private val uri: Uri) {
|
|||
}
|
||||
|
||||
@SuppressLint("CustomX509TrustManager")
|
||||
class TrustManager: X509TrustManager {
|
||||
class TrustManager : X509TrustManager {
|
||||
@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?) {
|
||||
Log.d(TAG, "cool cert, please continue")
|
||||
|
|
|
@ -3,7 +3,7 @@ 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.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.launch
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.Charset
|
||||
|
@ -51,44 +51,56 @@ class Response(val code: Code, val meta: String, val data: Channel<ByteArray>) {
|
|||
|
||||
companion object {
|
||||
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. */
|
||||
suspend fun from(channel: Channel<ByteArray>, scope: CoroutineScope): Response? {
|
||||
var received = 0
|
||||
val headerBuffer = ByteBuffer.allocate(1024)
|
||||
for (data in channel) {
|
||||
var lfIndex: Int
|
||||
// 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.
|
||||
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
|
||||
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<ByteArray>()
|
||||
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) }
|
||||
responseChannel.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
|
||||
break
|
||||
}
|
||||
return null
|
||||
// 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.i(TAG, "companion from: can't parse header") }
|
||||
val response = Response(code, meta, Channel())
|
||||
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)
|
||||
response.data.send(trailingData)
|
||||
}
|
||||
// Forward all incoming data to the Response channel.
|
||||
for (data in channel)
|
||||
response.data.send(data)
|
||||
response.data.close()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/** Return the code and meta from this header if it could be parsed correctly. */
|
||||
|
|
Reference in a new issue