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

321 lines
10 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 java.io.*
import java.net.ConnectException
import java.net.URI
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.*
const val GEMINI_SCHEME = "gemini"
/**
*
*
* @param protocol see: https://docs.oracle.com/javase/7/docs/technotes/guides/security/StandardNames.html#SSLContext
*
*/
class GeminiDatasource(
private val context: Context
): Datasource {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private var last: URI? = null
override fun request(uri: URI, onUpdate: (state: GemState) -> Unit) {
//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 -> {
val cached = RuntimeCache.get(uri)
if (cached != null) {
last = uri
onUpdate(
GemState.ResponseGemtext(
uri,
cached.first,
cached.second
)
)
return
} else {
onUpdate(GemState.Requesting(uri))
GlobalScope.launch {
geminiRequest(uri, onUpdate)
}
}
}
else -> {
val address = uri.toString()
val parsedUri = when {
address.startsWith("//") -> {
//just missing protocol
URI.create("gemini:$address")
}
address.startsWith("/") -> {
//internal navigation
val internalNav = "gemini://${last?.host}$address"
URI.create(internalNav)
}
!address.contains("://") -> {
//looks like a relative link
val lastAddress = last.toString()
val relAddress = "${lastAddress.substring(
0,
lastAddress.lastIndexOf("/") + 1
)}$address"
URI.create(relAddress)
}
else -> {
onUpdate(GemState.NotGeminiRequest(uri))
return
}
}
val cached = RuntimeCache.get(parsedUri)
if(cached != null){
last = parsedUri
onUpdate(
GemState.ResponseGemtext(
parsedUri,
cached.first,
cached.second
)
)
}else{
request(parsedUri, onUpdate)
}
}
}
}
/**
*
* This was originally largely copied from:
https://framagit.org/waweic/gemini-client/-/blob/master/app/src/main/java/rocks/ism/decentral/geminiclient/GeminiConnection.kt
*
*/
private fun geminiRequest(uri: URI, onUpdate: (state: GemState) -> Unit){
last = uri
val port = if(uri.port == -1) 1965 else uri.port
val protocol = prefs.getString("tls_protocol", "TLS")
println("REQ_PROTOCOL: $protocol")
//todo - extract and reuse this
val sslContext = if(protocol == "TLS_ALL"){
SSLContext.getInstance("TLS")
}else{
SSLContext.getInstance(protocol)
}
sslContext.init(null, DummyTrustManager.get(), null)
val factory: SSLSocketFactory = sslContext.socketFactory
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)
}
socket.startHandshake()
}catch (ce: ConnectException){
println("socket error: $ce")
onUpdate(
GemState.ResponseError(
GeminiResponse.Header(
-1,
ce.message ?: ce.toString()
)
)
)
return
}catch (she: SSLHandshakeException){
println("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)
outWriter.print(uri.toString() + "\r\n")
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")
val header = GeminiResponse.parseHeader(headerLine)
when {
header.code == GeminiResponse.INPUT -> onUpdate(GemState.ResponseInput(uri, header))
header.code == GeminiResponse.REDIRECT -> request(URI.create(header.meta), 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 -> onUpdate(GemState.ResponseError(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://") -> RuntimeCache.put(uri, header, processed)
}
onUpdate(GemState.ResponseGemtext(uri, header, processed))
}
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('/')
if (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 -> {
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()
)
)
}
}
}
}
}