Commit 4cbef970 authored by Waweic's avatar Waweic 💬
Browse files

Merge branch 'master' into 'patch-1'

# Conflicts:
#   app/src/main/java/rocks/ism/decentral/geminiclient/MainActivity.kt
parents 94ad42a5 ac09f06a
Pipeline #290566 failed with stages
in 5 minutes and 45 seconds
# Copied from: https://about.gitlab.com/blog/2018/10/24/setting-up-gitlab-ci-for-android-projects/
image: openjdk:8-jdk
variables:
ANDROID_COMPILE_SDK: "29"
ANDROID_BUILD_TOOLS: "29.0.3"
ANDROID_SDK_TOOLS: "4333796"
before_script:
- apt-get --quiet update --yes
- apt-get --quiet install --yes wget tar unzip lib32stdc++6 lib32z1
- wget --quiet --output-document=android-sdk.zip https://dl.google.com/android/repository/sdk-tools-linux-${ANDROID_SDK_TOOLS}.zip
- unzip -d android-sdk-linux android-sdk.zip
- echo y | android-sdk-linux/tools/bin/sdkmanager "platforms;android-${ANDROID_COMPILE_SDK}" >/dev/null
- echo y | android-sdk-linux/tools/bin/sdkmanager "platform-tools" >/dev/null
- echo y | android-sdk-linux/tools/bin/sdkmanager "build-tools;${ANDROID_BUILD_TOOLS}" >/dev/null
- export ANDROID_HOME=$PWD/android-sdk-linux
- export PATH=$PATH:$PWD/android-sdk-linux/platform-tools/
- chmod +x ./gradlew
# temporarily disable checking for EPIPE error and use yes to accept all licenses
- set +o pipefail
- yes | android-sdk-linux/tools/bin/sdkmanager --licenses
- set -o pipefail
stages:
- build
- test
lintDebug:
stage: build
script:
- ./gradlew -Pci --console=plain :app:lintDebug -PbuildDir=lint
assembleDebug:
stage: build
script:
- ./gradlew assembleDebug
artifacts:
paths:
- app/build/outputs/
debugTests:
stage: test
script:
- ./gradlew -Pci --console=plain :app:testDebug
<component name="ProjectDictionaryState">
<dictionary name="Anonymous">
<words>
<w>decentral</w>
<w>geminiclient</w>
<w>redirections</w>
</words>
</dictionary>
</component>
\ No newline at end of file
......@@ -12,30 +12,31 @@ If you want to download the Application without building it yourself, you can do
* Viewing formatted text/gemini documents
- Clickable links
- Bullet points
- Headings
* Viewing other text/* documents
* Open application when gemini:// link in other app / browser is clicked
* Open other application when a link is clicked that has a scheme the client can't handle
* Basic redirection
* Does not start a new Activity when you click anywhere (thanks to a much appreciated hint by [@utf8equalsX@fosstodon.org](https://fosstodon.org/@utf8equalsX))
* Avoiding redirection loops
* The "Back"-button
* An (internal and temporary) browsing history
## Stuff that doesn't work (aka wishlist for the future)
* A browsing history
* The "Back"-button
* Avoiding redirection loops
* Other character encodings than utf-8
* Connects to hosts with self signed certificates
* Connecting to hosts with self signed certificates
* Certain SSL/TLS Versions on older Android versions (I probably can't fix this)
* Downloading anything that is not of type text/*
* Crashes when page is too big
* Crashes when page is too big (feels just like a "real" web browser! #inabiaf)
* Caching for offline use
* Useful error messages
* Graceful error handling
* Markdown
* Headings in text/gemini documents
* Code quality / structure
* Gemini INPUT
* Client certificates
* Security
* Preformatted Text in Gemini documents
## Stuff that probably should be done differently
* Handling other character encodings than utf-8
* Probably a lot
\ No newline at end of file
......@@ -27,12 +27,12 @@ android {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.71"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'
implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.core:core-ktx:1.0.2'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.core:core-ktx:1.2.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
testImplementation 'junit:junit:4.12'
testImplementation 'junit:junit:4.13'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}
package rocks.ism.decentral.geminiclient
import android.content.Context
import android.widget.EditText
import android.widget.TextView
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import java.net.URI
class GeminiClient(applicationContext: Context, mainContentView: TextView, val uriField: EditText) {
val connection : GeminiConnection = GeminiConnection()
val renderer : GeminiRenderer = GeminiRenderer(mainContentView, this)
val responseHandler : GeminiResponseHandler = GeminiResponseHandler(renderer, applicationContext, this)
private val history: MutableList<URI> = mutableListOf()
fun goToPage(uri: URI) {
if(history.size == 0 || history[history.size - 1] != uri) {
history.add(uri) //No double entries. Useful for reloading and going back //TODO: Make this comparison more reproducible
}
uriField.setText(uri.toString())
val deferredResult = GlobalScope.async { connection.request(uri) }
GlobalScope.launch { responseHandler.handle(deferredResult) }
}
fun goToPage(uri: String) {
//TODO: Do checking and stuff
goToPage(URI.create(uri))
}
fun removeLastHistoryItem() {
history.removeAt(history.size - 1)
}
fun goBack(){
removeLastHistoryItem()
goToPage(history[history.size - 1]) //There has to be at least one item in the history //TODO: Double-check this
}
}
\ No newline at end of file
package rocks.ism.decentral.geminiclient
import android.graphics.Bitmap
import android.os.AsyncTask
import android.util.Log
import java.io.*
import java.net.URI
import javax.net.ssl.SSLSocket
......@@ -11,7 +10,7 @@ import javax.net.ssl.SSLSocketFactory
class GeminiConnection {
var currentURI : URI? = null
private set
suspend fun request(uri: URI): GeminiResult {
fun request(uri: URI): GeminiResult {
currentURI = uri
if(uri.scheme != "gemini") {
return GeminiResult(this, GeminiConnectionState.FAILED_WRONG_URI_SCHEME)
......@@ -25,6 +24,7 @@ class GeminiConnection {
val factory: SSLSocketFactory = SSLSocketFactory.getDefault() as SSLSocketFactory
val socket: SSLSocket = factory.createSocket(uri.host, port) as SSLSocket
socket.enabledProtocols = arrayOf("TLSv1.2")
socket.startHandshake()
val outWriter = PrintWriter(
......@@ -42,20 +42,22 @@ class GeminiConnection {
return GeminiResult(this, GeminiConnectionState.FAILED_PRINTER_WRITER)
}
var inReader = BufferedReader(
val inReader = BufferedReader(
InputStreamReader(
socket.inputStream
)
)
var responseHeader: String = inReader.readLine()
var responseBody: String = inReader.readText()
val responseHeader: String = inReader.readLine()
//val responseBody: String = inReader.readText()
inReader.close()
outWriter.close()
/*
socket.close()
*/
return GeminiResult(this, GeminiConnectionState.SUCCESS, responseHeader, responseBody)
return GeminiResult(this, GeminiConnectionState.SUCCESS, responseHeader, socket.inputStream)
}
}
......@@ -76,7 +78,7 @@ class GeminiResult(val connection: GeminiConnection, val connectionState: Gemini
var meta: String = ""
private set
var body: String = ""
lateinit var body: InputStream
private set
constructor (connection: GeminiConnection, connectionState: GeminiConnectionState, header: String) : this(connection, connectionState) {
......@@ -86,7 +88,7 @@ class GeminiResult(val connection: GeminiConnection, val connectionState: Gemini
this.statusCode = headerParts[0].toInt() //TODO: Check if not an int
this.meta = headerParts[1]
}
constructor (connection: GeminiConnection, connectionState: GeminiConnectionState, header: String, body: String) : this(connection, connectionState, header) {
constructor (connection: GeminiConnection, connectionState: GeminiConnectionState, header: String, body: InputStream) : this(connection, connectionState, header) {
this.body = body
}
......
package rocks.ism.decentral.geminiclient
import android.text.Html
import android.graphics.Typeface
import android.provider.Settings
import android.text.SpannableString
import android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
import android.text.method.LinkMovementMethod
import android.text.style.BulletSpan
import android.text.style.RelativeSizeSpan
import android.text.style.StyleSpan
import android.text.style.URLSpan
import android.util.Log
import android.widget.TextView
import android.widget.Toast
import kotlinx.coroutines.*
import java.io.BufferedReader
import java.io.InputStream
import java.io.InputStreamReader
import java.io.StringReader
import java.util.*
class GeminiRenderer (val textView: TextView) {
class GeminiRenderer (val textView: TextView, val geminiClient: GeminiClient) {
var preRenderMode = false
fun render(connection: GeminiConnection, resultBody: String, mimetype: String) {
fun render(resultBody: String, mimetype: String) {
textView.text = ""
textView.movementMethod = LinkMovementMethod.getInstance()
if(mimetype == "text/gemini") {
renderGemini(resultBody, connection)
renderGemini(resultBody)
}
else {
renderPlaintext(resultBody)
}
}
fun renderGemini(resultBody: String, connection: GeminiConnection) {
fun renderStream(inputStream: InputStream, mimetype: String) {
val scanner = Scanner(inputStream, "utf-8")
CoroutineScope(Dispatchers.Main).launch {
textView.text = ""
textView.movementMethod = LinkMovementMethod.getInstance()
/*
var line: String?
do {
line = GlobalScope.async {
if(scanner.hasNextLine()) {
scanner.nextLine()
} else {
null
}
}.await()
line?.let {renderLine(it, mimetype)}
} while(line != null)
*/
var allLines = GlobalScope.async {
BufferedReader (
inputStream.reader()
).readLines()
}
allLines.await().forEach() {
renderLine(it, mimetype)
}
}
}
fun renderLine(line: String, mimetype: String) {
if(mimetype == "text/gemini") {
renderGeminiLine(line)
}
else {
textView.append(line + "\n")
}
}
fun renderGemini(resultBody: String) {
val bodyReader = BufferedReader(StringReader(resultBody))
preRenderMode = false
textView.text = ""
textView.movementMethod = LinkMovementMethod.getInstance()
var line: String? = null
while ({ line = bodyReader.readLine(); line }() != null) {
line?.let {
renderGeminiLine(it, connection)
renderGeminiLine(it)
}
}
}
fun renderGeminiLine (line: String, connection: GeminiConnection) {
if(line.startsWith("=>")) {
val linkParts = line.substring(2).trim().split("\\s+".toRegex(), 2)
val linkUrl = connection.currentURI?.resolve(linkParts[0]).toString()
var linkName = linkParts[0]
if(linkParts.size > 1) {
linkName = linkParts[1]
fun renderGeminiLine (line: String) {
textView.append(when {
line.startsWith("###") -> {
val spannable = SpannableString(line.substring(3).trim())
spannable.setSpan(StyleSpan(Typeface.BOLD), 0, spannable.length, SPAN_EXCLUSIVE_EXCLUSIVE)
spannable
}
val spannable = SpannableString(linkName)
spannable.setSpan(URLSpan(linkUrl), 0, spannable.length, SPAN_EXCLUSIVE_EXCLUSIVE)
textView.append(spannable)
} else if(line.startsWith("*")) {
val spannable = SpannableString(line.substring(1))
spannable.setSpan(BulletSpan(), 0, spannable.length, SPAN_EXCLUSIVE_EXCLUSIVE)
textView.append(spannable)
}
else {
textView.append(line)
}
line.startsWith("##") -> {
val spannable = SpannableString(line.substring(2).trim())
spannable.setSpan(StyleSpan(Typeface.BOLD), 0, spannable.length, SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.setSpan(RelativeSizeSpan(1.1f),0, spannable.length, SPAN_EXCLUSIVE_EXCLUSIVE)
spannable
}
line.startsWith("#") -> {
val spannable = SpannableString(line.substring(1).trim())
spannable.setSpan(StyleSpan(Typeface.BOLD), 0, spannable.length, SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.setSpan(RelativeSizeSpan(1.2f),0, spannable.length, SPAN_EXCLUSIVE_EXCLUSIVE)
spannable
}
line.startsWith("=>") -> {
val linkParts = line.substring(2).trim().split("\\s+".toRegex(), 2)
val linkUrl = geminiClient.connection.currentURI?.resolve(linkParts[0]).toString()
var linkName = linkParts[0]
if(linkParts.size > 1) {
linkName = linkParts[1]
}
val spannable = SpannableString(linkName)
spannable.setSpan(URLSpan(linkUrl), 0, spannable.length, SPAN_EXCLUSIVE_EXCLUSIVE)
spannable
}
line.startsWith("*") -> {
val spannable = SpannableString(line.substring(1))
spannable.setSpan(BulletSpan(), 0, spannable.length, SPAN_EXCLUSIVE_EXCLUSIVE)
spannable
}
else -> {
line
}
})
textView.append("\n")
}
......
......@@ -3,33 +3,42 @@ package rocks.ism.decentral.geminiclient
import android.content.Context
import android.content.Intent
import android.net.Uri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
class GeminiResponseHandler (val renderer: GeminiRenderer, private val context: Context) {
fun handle (deferredResult: Deferred<GeminiResult>) {
CoroutineScope(Dispatchers.Main).launch {
class GeminiResponseHandler (val renderer: GeminiRenderer, private val context: Context, val geminiClient: GeminiClient) {
var numberOfConsecutiveRedirects = 0
suspend fun handle (deferredResult: Deferred<GeminiResult>) {
val result = deferredResult.await()
if(result.header.startsWith("3")) {
//TODO: Check stuff (like how many redirects were before this)
val newUriString = result.connection.currentURI?.resolve(result.meta).toString()
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(newUriString), context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
//TODO: Check stuff (like whether we are redirecting to a page without encryption)
if(++numberOfConsecutiveRedirects > 5) {
renderer.render("ERROR: Too many redirections","text/plain")
}
else {
geminiClient.removeLastHistoryItem() //Remove the link that was originally clicked so the Back-Button works
val newUriString = result.connection.currentURI?.resolve(result.meta).toString()
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(newUriString),
context,
MainActivity::class.java
)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent) //TODO: Is this dangerous? Possibly, the camera could probably be opened with this
}
}
else if(result.header.startsWith("2")){
numberOfConsecutiveRedirects = 0
if(result.meta == "text/gemini") {
renderer.render(result.connection, result.body, "text/gemini")
renderer.renderStream(result.body, "text/gemini")
} else if (result.meta.startsWith("text/")) {
renderer.render(result.connection, result.body,"text/plain")
renderer.renderStream(result.body,"text/plain")
}
}
else {
renderer.render(result.connection, result.header,"text/plain")
renderer.render(result.header,"text/plain")
}
}
}
}
\ No newline at end of file
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment