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() 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() ) ) } } } } }