You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
332 lines
14 KiB
332 lines
14 KiB
package dev.lowrespalmtree.comet
|
|
|
|
import android.content.ContentResolver
|
|
import android.content.Context
|
|
import android.net.Uri
|
|
import android.util.Log
|
|
import androidx.lifecycle.MutableLiveData
|
|
import androidx.lifecycle.SavedStateHandle
|
|
import androidx.lifecycle.ViewModel
|
|
import androidx.lifecycle.viewModelScope
|
|
import androidx.preference.PreferenceManager
|
|
import dev.lowrespalmtree.comet.utils.downloadMedia
|
|
import dev.lowrespalmtree.comet.utils.resolveLinkUri
|
|
import kotlinx.coroutines.*
|
|
import kotlinx.coroutines.channels.Channel
|
|
import kotlinx.coroutines.channels.onSuccess
|
|
import java.net.ConnectException
|
|
import java.net.SocketTimeoutException
|
|
import java.net.UnknownHostException
|
|
import java.nio.charset.Charset
|
|
|
|
class PageViewModel(
|
|
@Suppress("unused") private val savedStateHandle: SavedStateHandle
|
|
) : ViewModel() {
|
|
/** Currently viewed page URL. */
|
|
var currentUrl: String = ""
|
|
|
|
/** The first level 1 header of the current page (default is an empty string). */
|
|
var currentTitle: String = ""
|
|
|
|
/** Latest Uri requested using `sendGeminiRequest`. */
|
|
var loadingUrl: Uri? = null
|
|
|
|
/** Observable page viewer state. */
|
|
val state: MutableLiveData<State> by lazy { MutableLiveData<State>(State.IDLE) }
|
|
|
|
/** Observable page viewer lines (backed up by `linesList` but updated less often). Left element is associated URL. */
|
|
val lines: MutableLiveData<Pair<String, List<Line>>> by lazy { MutableLiveData<Pair<String, List<Line>>>() }
|
|
|
|
/** Observable page viewer latest event. */
|
|
val event: MutableLiveData<Event> by lazy { MutableLiveData<Event>() }
|
|
|
|
/** A non-saved list of visited URLs. Not an history, just used for going back. */
|
|
val visitedUrls = mutableListOf<String>()
|
|
|
|
/** Latest request job created, stored to cancel it if needed. */
|
|
private var requestJob: Job? = null
|
|
|
|
/** Lines for the current page. */
|
|
private var linesList = ArrayList<Line>()
|
|
|
|
/** Page state to be reflected on the UI (e.g. loading bar). */
|
|
enum class State {
|
|
IDLE, CONNECTING, RECEIVING
|
|
}
|
|
|
|
/** Generic event class to notify observers with. The handled flag avoids repeated usage. */
|
|
abstract class Event(var handled: Boolean = false)
|
|
|
|
/** An user input has been requested from the URI, with this prompt. */
|
|
data class InputEvent(val uri: Uri, val prompt: String) : Event()
|
|
|
|
/** The server responded with a success code and *has finished* its response. */
|
|
data class SuccessEvent(val uri: String, val mainTitle: String?) : Event()
|
|
|
|
/** The server responded with a success code and a binary MIME type (not delivered yet). */
|
|
data class BinaryEvent(
|
|
val uri: Uri,
|
|
val response: Response,
|
|
val mimeType: MimeType
|
|
) : Event()
|
|
|
|
/** A file has been completely downloaded. */
|
|
data class DownloadCompletedEvent(
|
|
val uri: Uri,
|
|
val mimeType: MimeType
|
|
) : Event()
|
|
|
|
/** The server is redirecting us. */
|
|
data class RedirectEvent(val uri: String, val sourceUri: String, val redirects: Int) : Event()
|
|
|
|
/** The server responded with a failure code or we encountered a local issue. */
|
|
data class FailureEvent(
|
|
val short: String,
|
|
val details: String,
|
|
val serverDetails: String? = null
|
|
) : Event()
|
|
|
|
/**
|
|
* Perform a request against this URI.
|
|
*
|
|
* @param uri URI to open; must be valid, absolute and with a gemini scheme
|
|
* @param context Context used to retrieve user preferences, not stored
|
|
* @param redirects current number of redirections operated
|
|
*/
|
|
@ExperimentalCoroutinesApi
|
|
fun sendGeminiRequest(
|
|
uri: Uri,
|
|
context: Context,
|
|
redirects: Int = 0
|
|
) {
|
|
Log.i(TAG, "sendGeminiRequest: URI \"$uri\"")
|
|
loadingUrl = uri
|
|
|
|
// Retrieve various request parameters from user preferences.
|
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
|
val protocol =
|
|
prefs.getString("tls_version", Request.DEFAULT_TLS_VERSION)!!
|
|
val connectionTimeout =
|
|
prefs.getInt("connection_timeout", Request.DEFAULT_CONNECTION_TIMEOUT_SEC)
|
|
val readTimeout =
|
|
prefs.getInt("read_timeout", Request.DEFAULT_READ_TIMEOUT_SEC)
|
|
|
|
state.postValue(State.CONNECTING)
|
|
|
|
requestJob?.apply { if (isActive) cancel() }
|
|
requestJob = viewModelScope.launch(Dispatchers.IO) {
|
|
// Look for a suitable identity to use with this URL.
|
|
val keyManager = Identities.getForUrl(uri.toString())?.let {
|
|
Log.d(TAG, "sendGeminiRequest coroutine: using identity with key ${it.key}")
|
|
Request.KeyManager.fromAlias(it.key)
|
|
}
|
|
|
|
// Connect to the server and proceed (no TOFU validation yet).
|
|
val response = try {
|
|
val request = Request(uri, keyManager = keyManager)
|
|
val socket = request.connect(protocol, connectionTimeout, readTimeout)
|
|
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}."
|
|
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.INPUT ->
|
|
handleInputResponse(response, uri)
|
|
Response.Code.Category.SUCCESS ->
|
|
handleSuccessResponse(response, uri)
|
|
Response.Code.Category.REDIRECT ->
|
|
handleRedirectResponse(response, uri, redirects = redirects + 1)
|
|
Response.Code.Category.SERVER_ERROR, Response.Code.Category.CLIENT_ERROR ->
|
|
handleErrorResponse(response)
|
|
else ->
|
|
signalError("Can't handle code ${response.code}.")
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Notify observers that an error happened, with a generic short message. Set state to idle. */
|
|
private fun signalError(message: String) {
|
|
event.postValue(FailureEvent("Error", message))
|
|
state.postValue(State.IDLE)
|
|
}
|
|
|
|
/** Notify observers that user input has been requested. */
|
|
private fun handleInputResponse(response: Response, uri: Uri) {
|
|
event.postValue(InputEvent(uri, response.meta))
|
|
state.postValue(State.IDLE)
|
|
}
|
|
|
|
/** Continue processing a successful response by looking at the provided MIME type. */
|
|
@ExperimentalCoroutinesApi
|
|
private suspend fun handleSuccessResponse(response: Response, uri: Uri) {
|
|
val mimeType = MimeType.from(response.meta) ?: MimeType.DEFAULT // Spec. section 3.3 last §
|
|
when (mimeType.main) {
|
|
"text" -> {
|
|
if (mimeType.sub == "gemini")
|
|
handleSuccessGemtextResponse(response, uri)
|
|
else
|
|
handleSuccessGenericTextResponse(response, uri)
|
|
}
|
|
else -> event.postValue(BinaryEvent(uri, response, mimeType))
|
|
}
|
|
}
|
|
|
|
/** Receive Gemtext data, parse it and send the lines to observers. */
|
|
@ExperimentalCoroutinesApi
|
|
private suspend fun handleSuccessGemtextResponse(response: Response, uri: Uri) {
|
|
state.postValue(State.RECEIVING)
|
|
val uriString = uri.toString()
|
|
|
|
linesList.clear()
|
|
lines.postValue(Pair(uriString, linesList))
|
|
val charset = Charset.defaultCharset()
|
|
var mainTitle: String? = null
|
|
var lastUpdate = System.currentTimeMillis()
|
|
var lastNumLines = 0
|
|
Log.d(TAG, "handleSuccessResponse: start parsing line data")
|
|
try {
|
|
val lineChannel = parseData(response.data, charset, viewModelScope)
|
|
while (!lineChannel.isClosedForReceive) {
|
|
val lineChannelResult = withTimeout(100) { lineChannel.tryReceive() }
|
|
lineChannelResult.onSuccess { line ->
|
|
if (line is LinkLine) {
|
|
// Mark visited links here as we have a access to the history.
|
|
val fullUrl = resolveLinkUri(line.url, uriString).toString()
|
|
if (History.contains(fullUrl))
|
|
line.visited = true
|
|
}
|
|
linesList.add(line)
|
|
// Get the first level 1 header as the page main title.
|
|
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(Pair(uriString, linesList))
|
|
lastUpdate = time
|
|
lastNumLines = linesList.size
|
|
}
|
|
}
|
|
}
|
|
} catch (e: CancellationException) {
|
|
Log.e(TAG, "handleSuccessResponse: coroutine cancelled: ${e.message}")
|
|
state.postValue(State.IDLE)
|
|
return
|
|
}
|
|
Log.d(TAG, "handleSuccessResponse: done parsing line data")
|
|
lines.postValue(Pair(uriString, 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(uriString, mainTitle)
|
|
event.postValue(SuccessEvent(uriString, mainTitle))
|
|
state.postValue(State.IDLE)
|
|
}
|
|
|
|
/** Receive generic text data (e.g. text/plain) and send it to observers. */
|
|
@ExperimentalCoroutinesApi
|
|
private suspend fun handleSuccessGenericTextResponse(response: Response, uri: Uri) =
|
|
handleSuccessGemtextResponse(response, uri) // TODO render plain text as... something else?
|
|
|
|
/** Notify observers that a redirect has been returned. */
|
|
private fun handleRedirectResponse(response: Response, uri: Uri, redirects: Int) {
|
|
event.postValue(RedirectEvent(response.meta, uri.toString(), redirects))
|
|
}
|
|
|
|
/**
|
|
* Provide an error message to the user corresponding to the error code.
|
|
*
|
|
* TODO This requires a lot of localisation.
|
|
*/
|
|
private fun handleErrorResponse(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 = 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.SLOW_DOWN -> "You should wait ${response.meta.toIntOrNull() ?: "a few"} seconds before retrying."
|
|
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."
|
|
}
|
|
var serverMessage: String? = null
|
|
if (response.code != Response.Code.SLOW_DOWN && response.meta.isNotEmpty())
|
|
serverMessage = response.meta
|
|
event.postValue(FailureEvent(briefMessage, longMessage, serverMessage))
|
|
state.postValue(State.IDLE)
|
|
}
|
|
|
|
/** Download response content as a file. */
|
|
@ExperimentalCoroutinesApi
|
|
fun downloadResponse(
|
|
channel: Channel<ByteArray>,
|
|
uri: Uri,
|
|
mimeType: MimeType,
|
|
contentResolver: ContentResolver
|
|
) {
|
|
when (mimeType.main) {
|
|
"image", "audio", "video" -> {
|
|
downloadMedia(
|
|
channel, uri, mimeType, viewModelScope, contentResolver,
|
|
onSuccess = { mediaUri ->
|
|
event.postValue(DownloadCompletedEvent(mediaUri, mimeType))
|
|
state.postValue(State.IDLE)
|
|
viewModelScope.launch(Dispatchers.IO) { History.record(uri.toString()) }
|
|
},
|
|
onError = { msg -> signalError("Download failed: $msg") }
|
|
)
|
|
}
|
|
else -> {
|
|
// TODO use SAF
|
|
signalError("MIME type unsupported yet: ${mimeType.main} (\"${mimeType.short}\")")
|
|
}
|
|
}
|
|
}
|
|
|
|
companion object {
|
|
private const val TAG = "PageViewModel"
|
|
}
|
|
} |