MainActivity: proper alerts for each error type

Also moved the PageViewModel into its own file? What's the norm?
This commit is contained in:
dece 2021-12-13 23:35:12 +01:00
parent a2dd6f4876
commit 781a4a66b0
3 changed files with 203 additions and 157 deletions

View file

@ -11,18 +11,10 @@ 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.* import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.onSuccess
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.nio.charset.Charset
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen { class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
@ -129,16 +121,23 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
currentUrl = event.uri currentUrl = event.uri
visitedUrls.add(event.uri) visitedUrls.add(event.uri)
} }
is PageViewModel.RedirectEvent -> openUrl(event.uri, redirections = event.redirects) is PageViewModel.RedirectEvent -> {
is PageViewModel.FailureEvent -> alert(event.message) 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) { private fun alert(message: String, title: String? = null) {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(R.string.error_alert_title) .setTitle(title ?: getString(R.string.error_alert_title))
.setMessage(message) .setMessage(message)
.create() .create()
.show() .show()
@ -152,148 +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.localizedMessage}."
is SocketTimeoutException -> "Connection timed out."
is CancellationException -> "Connection cancelled: ${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()
var lastNumLines = 0
Log.d(TAG, "handleRequestSuccess: start parsing line data")
try {
val lineChannel = parseData(response.data, charset, viewModelScope)
while (!lineChannel.isClosedForReceive) {
val lineChannelResult = withTimeout(100) { lineChannel.tryReceive() }
lineChannelResult.onSuccess { line ->
linesList.add(line)
if (mainTitle == null && line is TitleLine && line.level == 1)
mainTitle = line.text
}
// Throttle the recycler view updates to 100ms and new content only.
if (linesList.size > lastNumLines) {
val time = System.currentTimeMillis()
if (time - lastUpdate >= 100) {
lines.postValue(linesList)
lastUpdate = time
lastNumLines = linesList.size
}
}
}
} catch (e: CancellationException) {
Log.e(TAG, "handleRequestSuccess: coroutine cancelled: ${e.message}")
state.postValue(State.IDLE)
return
}
Log.d(TAG, "handleRequestSuccess: done parsing line data")
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

@ -0,0 +1,183 @@
package dev.lowrespalmtree.comet
import android.net.Uri
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.onSuccess
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.nio.charset.Charset
@ExperimentalCoroutinesApi
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 short: String,
val details: String,
val serverDetails: String? = null
) : 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.localizedMessage}."
is SocketTimeoutException -> "Connection timed out."
is CancellationException -> "Connection cancelled: ${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, Response.Code.Category.CLIENT_ERROR ->
handleError(response)
else ->
signalError("Can't handle code ${response.code}.")
}
}
}
private fun signalError(message: String) {
event.postValue(FailureEvent("Error", 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()
var lastNumLines = 0
Log.d(TAG, "handleRequestSuccess: start parsing line data")
try {
val lineChannel = parseData(response.data, charset, viewModelScope)
while (!lineChannel.isClosedForReceive) {
val lineChannelResult = withTimeout(100) { lineChannel.tryReceive() }
lineChannelResult.onSuccess { line ->
linesList.add(line)
if (mainTitle == null && line is TitleLine && line.level == 1)
mainTitle = line.text
}
// Throttle the recycler view updates to 100ms and new content only.
if (linesList.size > lastNumLines) {
val time = System.currentTimeMillis()
if (time - lastUpdate >= 100) {
lines.postValue(linesList)
lastUpdate = time
lastNumLines = linesList.size
}
}
}
} catch (e: CancellationException) {
Log.e(TAG, "handleRequestSuccess: coroutine cancelled: ${e.message}")
state.postValue(State.IDLE)
return
}
Log.d(TAG, "handleRequestSuccess: done parsing line data")
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) {
val briefMessage = when (response.code) {
Response.Code.TEMPORARY_FAILURE -> "40 Temporary failure"
Response.Code.SERVER_UNAVAILABLE -> "41 Server unavailable"
Response.Code.CGI_ERROR -> "42 CGI error"
Response.Code.PROXY_ERROR -> "43 Proxy error"
Response.Code.SLOW_DOWN -> "44 Slow down"
Response.Code.PERMANENT_FAILURE -> "50 Permanent failure"
Response.Code.NOT_FOUND -> "51 Not found"
Response.Code.GONE -> "52 Gone"
Response.Code.PROXY_REQUEST_REFUSED -> "53 Proxy request refused"
Response.Code.BAD_REQUEST -> "59 Bad request"
else -> "${response.code} (unknown)"
}
val longMessage: String
var serverMessage: String? = null
if (response.code == Response.Code.SLOW_DOWN) {
longMessage =
"You should wait ${response.meta.toIntOrNull() ?: "a few"} seconds before retrying."
} else {
longMessage = when (response.code) {
Response.Code.TEMPORARY_FAILURE -> "The server encountered a temporary failure."
Response.Code.SERVER_UNAVAILABLE -> "The server is currently unavailable."
Response.Code.CGI_ERROR -> "A CGI script encountered an error."
Response.Code.PROXY_ERROR -> "The server failed to proxy the request."
Response.Code.PERMANENT_FAILURE -> "This request failed and similar requests will likely fail as well."
Response.Code.NOT_FOUND -> "This page can't be found."
Response.Code.GONE -> "This page is gone."
Response.Code.PROXY_REQUEST_REFUSED -> "The server refused to proxy the request."
Response.Code.BAD_REQUEST -> "Bad request."
else -> "Unknown error code."
}
if (response.meta.isNotEmpty())
serverMessage = response.meta
}
event.postValue(FailureEvent(briefMessage, longMessage, serverMessage))
}
companion object {
const val TAG = "PageViewModel"
}
}

View file

@ -51,6 +51,8 @@ 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? {
@ -62,7 +64,11 @@ class Response(val code: Code, val meta: String, val data: Channel<ByteArray>) {
val data = try { val data = try {
channel.receive() channel.receive()
} catch (e: ClosedReceiveChannelException) { } catch (e: ClosedReceiveChannelException) {
Log.d(TAG, "companion from: channel closed during initial receive") 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 return null
} }
// Push some data into our buffer. // Push some data into our buffer.
@ -80,7 +86,7 @@ class Response(val code: Code, val meta: String, val data: Channel<ByteArray>) {
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.e(TAG, "companion from: can't parse header") } ?: return null .also { Log.i(TAG, "companion from: can't parse header") }
val response = Response(code, meta, Channel()) val response = Response(code, meta, Channel())
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.