ariane/app/src/main/java/oppen/ariane/io/gemini/GeminiDatasource.kt

264 lines
9.9 KiB
Kotlin

package oppen.ariane.io.gemini
import android.content.Context
import androidx.core.net.toUri
import androidx.preference.PreferenceManager
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import oppen.ariane.io.GemState
import oppen.ariane.io.database.history.ArianeHistory
import oppen.ariane.io.database.history.HistoryEntry
import oppen.ariane.io.keymanager.ArianeKeyManager
import oppen.isGemini
import oppen.toURI
import oppen.toUri
import java.io.*
import java.lang.IllegalStateException
import java.net.ConnectException
import java.net.URI
import javax.net.ssl.*
const val GEMINI_SCHEME = "gemini"
class GeminiDatasource(private val context: Context, val history: ArianeHistory): Datasource {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val addressBuilder = AddressBuilder()
private val runtimeHistory = mutableListOf<URI>()
private var forceDownload = false
private val arianeKeyManager = ArianeKeyManager()
override fun request(uri: URI, onUpdate: (state: GemState) -> Unit) = request(uri, false, onUpdate)
override fun request(uri: URI, forceDownload: Boolean, onUpdate: (state: GemState) -> Unit) {
this.forceDownload = forceDownload
//Any inputted uri starting with a colon is an app-specific command, eg. :prefs :settings
if(uri.toString().startsWith(":")){
onUpdate(GemState.AppQuery(uri))
return
}
when (uri.scheme) {
GEMINI_SCHEME -> {
addressBuilder.set(uri.toUri())
val cached = RuntimeCache.get(uri)
when {
cached != null -> {
updateHistory(uri)
onUpdate(GemState.ResponseGemtext(uri, cached.first, cached.second))
return
}
else -> {
onUpdate(GemState.Requesting(uri))
GlobalScope.launch {
geminiRequest(uri, onUpdate)
}
}
}
}
else -> {
val parsedUri = addressBuilder.request(uri).uri()
if(parsedUri.isGemini()){
val cached = RuntimeCache.get(parsedUri)
when {
cached != null -> {
updateHistory(uri)
onUpdate(GemState.ResponseGemtext(parsedUri.toURI(), cached.first, cached.second))
}
else -> request(parsedUri.toURI(), forceDownload, onUpdate)
}
}else{
onUpdate(GemState.NotGeminiRequest(uri))
return
}
}
}
}
private fun geminiRequest(uri: URI, onUpdate: (state: GemState) -> Unit){
val port = if(uri.port == -1) 1965 else uri.port
//todo - extract and reuse this ------------------------------------------------------------
val protocol = prefs.getString("tls_protocol", "TLS")
println("REQ_PROTOCOL: $protocol")
val sslContext = when (protocol) {
"TLS_ALL" -> SSLContext.getInstance("TLS")
else -> SSLContext.getInstance(protocol)
}
sslContext.init(arianeKeyManager.getFactory(context)?.keyManagers, DummyTrustManager.get(), null)
val factory: SSLSocketFactory = sslContext.socketFactory
//todo to here ----------------------------------------------------------------------------
val socket: SSLSocket?
try {
socket = factory.createSocket(uri.host, port) as SSLSocket
when (protocol) {
"TLS" -> {}//Use default enabled protocols
"TLS_ALL" -> socket.enabledProtocols = socket.supportedProtocols
else -> socket.enabledProtocols = arrayOf(protocol)
}
println("Ariane socket handshake with ${uri.host} on port $port")
socket.startHandshake()
}catch (ce: ConnectException){
println("Ariane socket error: $ce")
onUpdate(GemState.ResponseError(GeminiResponse.Header(-1, ce.message ?: ce.toString())))
return
}catch (she: SSLHandshakeException){
println("Ariane socket error: $she")
onUpdate(GemState.ResponseError(GeminiResponse.Header(-2, she.message ?: she.toString())))
return
}
// OUT >>>>>>>>>>>>>>>>>>>>>>>>>>
val outputStreamWriter = OutputStreamWriter(socket.outputStream)
val bufferedWriter = BufferedWriter(outputStreamWriter)
val outWriter = PrintWriter(bufferedWriter)
val requestEntity = uri.toString() + "\r\n"
println("Ariane socket requesting $requestEntity")
outWriter.print(requestEntity)
outWriter.flush()
if (outWriter.checkError()) {
onUpdate(GemState.ResponseError(GeminiResponse.Header(-1, "Print Writer Error")))
outWriter.close()
return
}
// IN <<<<<<<<<<<<<<<<<<<<<<<<<<<
val inputStream = socket.inputStream
val headerInputReader = InputStreamReader(inputStream)
val bufferedReader = BufferedReader(headerInputReader)
val headerLine = bufferedReader.readLine()
println("Ariane: response header: $headerLine")
if(headerLine == null){
onUpdate(GemState.ResponseError(GeminiResponse.Header(-2, "Server did not respond with a Gemini header")))
return
}
val header = GeminiResponse.parseHeader(headerLine)
when {
header.code == GeminiResponse.INPUT -> onUpdate(GemState.ResponseInput(uri, header))
header.code == GeminiResponse.REDIRECT -> request(URI.create(header.meta), forceDownload, onUpdate)
header.code != GeminiResponse.SUCCESS -> onUpdate(GemState.ResponseError(header))
header.meta.startsWith("text/gemini") -> getGemtext(bufferedReader, uri, header, onUpdate)
header.meta.startsWith("text/") -> getString(socket, uri, header, onUpdate)
header.meta.startsWith("image/") -> getBinary(socket, uri, header, onUpdate)
header.meta.startsWith("audio/") -> getBinary(socket, uri, header, onUpdate)
else -> {
//File served over Gemini but not handled in-app, eg .pdf
if(forceDownload){
getBinary(socket, uri, header, onUpdate)
}else{
onUpdate(GemState.ResponseUnknownMime(uri, header))
}
}
}
//Close input
bufferedReader.close()
headerInputReader.close()
//Close output:
outputStreamWriter.close()
bufferedWriter.close()
outWriter.close()
socket.close()
}
private fun getGemtext(reader: BufferedReader, uri: URI, header: GeminiResponse.Header, onUpdate: (state: GemState) -> Unit){
val lines = mutableListOf<String>()
lines.addAll(reader.readLines())
val processed = GemtextHelper.findCodeBlocks(lines)
when {
!uri.toString().startsWith("gemini://") -> throw IllegalStateException("Not a Gemini Uri")
}
RuntimeCache.put(uri, header, processed)
updateHistory(uri)
onUpdate(GemState.ResponseGemtext(uri, header, processed))
}
private fun updateHistory(uri: URI) {
if (runtimeHistory.isEmpty() || runtimeHistory.last().toString() != uri.toString()) {
runtimeHistory.add(uri)
println("Ariane added $uri to runtime history (size ${runtimeHistory.size})")
}
history.add(uri.toUri()){}
}
private fun getString(socket: SSLSocket?, uri: URI, header: GeminiResponse.Header, onUpdate: (state: GemState) -> Unit){
val content = socket?.inputStream?.bufferedReader().use {
reader -> reader?.readText()
}
socket?.close()
onUpdate(GemState.ResponseText(uri, header, content ?: "Error fetching content"))
}
private fun getBinary(socket: SSLSocket?, uri: URI, header: GeminiResponse.Header, onUpdate: (state: GemState) -> Unit){
var filename: String? = null
val fileSegmentIndex: Int = uri.path.lastIndexOf('/')
when {
fileSegmentIndex != -1 -> filename = uri.path.substring(fileSegmentIndex + 1)
}
val host = uri.host.replace(".", "_")
val cacheName = "${host}_$filename"
println("Caching file: $filename from uri: $uri, cacheName: $cacheName")
val cacheFile = File(context.cacheDir, cacheName)
when {
cacheFile.exists() -> {
when {
header.meta.startsWith("image/") -> onUpdate(GemState.ResponseImage(uri, header, cacheFile.toUri()))
header.meta.startsWith("audio/") -> onUpdate(GemState.ResponseAudio(uri, header, cacheFile.toUri()))
else -> onUpdate(GemState.ResponseBinary(uri, header, cacheFile.toUri()))
}
}
else -> {
cacheFile.createNewFile()
cacheFile.outputStream().use{ outputStream ->
socket?.inputStream?.copyTo(outputStream)
socket?.close()
}
when {
header.meta.startsWith("image/") -> onUpdate(GemState.ResponseImage(uri, header, cacheFile.toUri()))
header.meta.startsWith("audio/") -> onUpdate(GemState.ResponseAudio(uri, header, cacheFile.toUri()))
else -> onUpdate(GemState.ResponseBinary(uri, header, cacheFile.toUri()))
}
}
}
}
override fun canGoBack(): Boolean = runtimeHistory.isEmpty() || runtimeHistory.size > 1
override fun goBack(onUpdate: (state: GemState) -> Unit) {
runtimeHistory.removeLast()
request(runtimeHistory.last(), onUpdate)
}
}