Skip to content

Commit ad7ea29

Browse files
committed
Adding more Gemini protocol specification support and fixing bugs
1 parent 93e0643 commit ad7ea29

File tree

13 files changed

+321
-239
lines changed

13 files changed

+321
-239
lines changed

app/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
77

88
---
99

10+
## [1.1.1] - 2025-04-27
11+
### Added
12+
- Gemtext support for preformatted toggle lines, quote lines, list items, lang parameter.
13+
- Support for more Gemini status codes, and laying groundwork for more.
14+
- Initial support for Gemini redirection.
15+
### Changed
16+
- Moved some Gemini specific code to the dev.parham.zapri.protocol.gemini package.
17+
- Fixed some bugs related to URLs not being rendered correctly.
18+
- Changed nex protocol icon in the address bar.
19+
1020
## [1.1.0] - 2025-04-25
1121
### Changed
1222
- Automated release test via GitHub Actions. No functional changes. Cleaned up the repo.

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ android {
1212
applicationId = "dev.parham.zapri"
1313
minSdk = 26
1414
targetSdk = 35
15-
versionCode = 14
16-
versionName = "1.1.0"
15+
versionCode = 15
16+
versionName = "1.1.1"
1717

1818
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
1919
}
Lines changed: 29 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package dev.parham.zapri.protocol
22

3-
import android.content.Context
3+
import androidx.compose.runtime.Composable
44
import dev.parham.zapri.data.model.PageData
55
import dev.parham.zapri.protocol.gemini.GeminiClient
6+
import dev.parham.zapri.protocol.gemini.GeminiParser
7+
import dev.parham.zapri.protocol.gemini.GeminiRenderer
8+
import dev.parham.zapri.protocol.gemini.GemtextElement
69
import dev.parham.zapri.utils.UrlParser
710

811
object ProtocolHandler {
912

10-
fun fetch(url: String, context: Context): PageData {
13+
fun fetch(url: String, context: android.content.Context): PageData {
1114
val parsedUrl = UrlParser.parse(url)
1215
?: return PageData(
1316
statusCode = -1,
@@ -19,46 +22,11 @@ object ProtocolHandler {
1922

2023
return when (parsedUrl.protocol) {
2124
"gemini" -> GeminiClient.fetch(url, context)
22-
"gopher" -> PageData(
25+
"gopher", "finger", "scroll", "nex", "spartan", "text" -> PageData(
2326
statusCode = -1,
2427
meta = "",
2528
content = null,
26-
errorMessage = "Gopher protocol is not yet implemented.",
27-
statusMessage = "Protocol Not Implemented"
28-
)
29-
"finger" -> PageData(
30-
statusCode = -1,
31-
meta = "",
32-
content = null,
33-
errorMessage = "Finger protocol is not yet implemented.",
34-
statusMessage = "Protocol Not Implemented"
35-
)
36-
"scroll" -> PageData(
37-
statusCode = -1,
38-
meta = "",
39-
content = null,
40-
errorMessage = "Scroll protocol is not yet implemented.",
41-
statusMessage = "Protocol Not Implemented"
42-
)
43-
"nex" -> PageData(
44-
statusCode = -1,
45-
meta = "",
46-
content = null,
47-
errorMessage = "Nex protocol is not yet implemented.",
48-
statusMessage = "Protocol Not Implemented"
49-
)
50-
"spartan" -> PageData(
51-
statusCode = -1,
52-
meta = "",
53-
content = null,
54-
errorMessage = "Spartan protocol is not yet implemented.",
55-
statusMessage = "Protocol Not Implemented"
56-
)
57-
"text" -> PageData(
58-
statusCode = -1,
59-
meta = "",
60-
content = null,
61-
errorMessage = "Text protocol is not yet implemented.",
29+
errorMessage = "${parsedUrl.protocol.capitalize()} protocol is not yet implemented.",
6230
statusMessage = "Protocol Not Implemented"
6331
)
6432
else -> PageData(
@@ -70,5 +38,26 @@ object ProtocolHandler {
7038
)
7139
}
7240
}
73-
}
7441

42+
fun parseContent(pageData: PageData, baseUrl: String): List<GemtextElement> {
43+
val parsedUrl = UrlParser.parse(baseUrl)
44+
?: return emptyList()
45+
46+
return when (parsedUrl.protocol) {
47+
"gemini" -> pageData.content?.let { GeminiParser.parse(it, baseUrl) } ?: emptyList()
48+
else -> emptyList() // Later, other protocol parsers can be added here
49+
}
50+
}
51+
52+
@Composable
53+
fun renderContent(
54+
elements: List<GemtextElement>,
55+
onLinkClick: (String) -> Unit
56+
) {
57+
val parsedUrl = elements.firstOrNull()
58+
// Currently assume it's Gemini (later: expand if different types)
59+
elements.forEach { element ->
60+
GeminiRenderer.RenderGemtextElement(element, onLinkClick)
61+
}
62+
}
63+
}

app/src/main/java/dev/parham/zapri/utils/CertificateStorage.kt renamed to app/src/main/java/dev/parham/zapri/protocol/gemini/CertificateStorage.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package dev.parham.zapri.utils
1+
package dev.parham.zapri.protocol.gemini
22

33
import android.content.Context
44
import java.io.File

app/src/main/java/dev/parham/zapri/protocol/gemini/GeminiClient.kt

Lines changed: 83 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ package dev.parham.zapri.protocol.gemini
22

33
import android.content.Context
44
import dev.parham.zapri.data.model.PageData
5-
import dev.parham.zapri.utils.CertificateStorage
65
import dev.parham.zapri.utils.UrlParser
76
import java.io.BufferedReader
87
import java.io.InputStreamReader
98
import java.io.OutputStreamWriter
109
import java.net.InetAddress
10+
import java.net.URL
1111
import java.net.UnknownHostException
1212
import java.security.SecureRandom
1313
import java.security.cert.CertificateException
@@ -18,29 +18,30 @@ object GeminiClient {
1818

1919
private const val GEMINI_PORT = 1965
2020
private const val MAX_CONTENT_SIZE = 10 * 1024 * 1024 // 10 MB limit for content
21+
private const val MAX_REDIRECTS = 5 // Maximum number of redirects to follow
2122

22-
fun fetch(url: String, context: Context): PageData {
23-
val parsedUrl = UrlParser.parse(url)
24-
?: return PageData(
23+
fun fetch(url: String, context: Context, redirectCount: Int = 0): PageData {
24+
if (redirectCount >= MAX_REDIRECTS) {
25+
return PageData(
2526
statusCode = -1,
2627
meta = "",
2728
content = null,
28-
errorMessage = "Error: Invalid URL. Please check the format.",
29-
statusMessage = "Invalid URL"
29+
errorMessage = "Error: Too many redirects",
30+
statusMessage = "Redirect Loop"
3031
)
32+
}
3133

32-
if (parsedUrl.protocol != "gemini") {
33-
return PageData(
34+
val parsedUrl = UrlParser.parse(url)
35+
?: return PageData(
3436
statusCode = -1,
3537
meta = "",
3638
content = null,
37-
errorMessage = "Error: Unsupported protocol '${parsedUrl.protocol}'.",
38-
statusMessage = "Unsupported Protocol"
39+
errorMessage = "Error: Invalid URL. Please check the format.",
40+
statusMessage = "Invalid URL"
3941
)
40-
}
4142

4243
val host = parsedUrl.host
43-
val path = parsedUrl.path
44+
val fullUrl = parsedUrl.fullUrl
4445

4546
return try {
4647
InetAddress.getByName(host)
@@ -57,7 +58,7 @@ object GeminiClient {
5758
val writer = OutputStreamWriter(socket.getOutputStream())
5859
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
5960

60-
writer.write("$path\r\n")
61+
writer.write("$fullUrl\r\n")
6162
writer.flush()
6263

6364
val statusLine = reader.readLine() ?: return PageData(
@@ -82,7 +83,21 @@ object GeminiClient {
8283
val meta = statusLine.substring(3)
8384

8485
when (statusCode) {
85-
20 -> {
86+
11 -> PageData(
87+
statusCode = statusCode,
88+
meta = meta,
89+
content = null,
90+
errorMessage = null,
91+
statusMessage = "Sensitive Input Required: $meta"
92+
)
93+
in 10..19 -> PageData(
94+
statusCode = statusCode,
95+
meta = meta,
96+
content = null,
97+
errorMessage = null,
98+
statusMessage = "Input Required: $meta"
99+
)
100+
in 20..29 -> {
86101
val content = buildString {
87102
var line: String?
88103
var totalSize = 0
@@ -108,13 +123,58 @@ object GeminiClient {
108123
statusMessage = "Success"
109124
)
110125
}
111-
30 -> PageData(
112-
statusCode = statusCode,
113-
meta = meta,
114-
content = null,
115-
errorMessage = null,
116-
statusMessage = "Redirect: $meta"
117-
)
126+
in 30..39 -> {
127+
val targetMeta = meta.trim()
128+
129+
val redirectUrl = try {
130+
val parsedRedirect = when {
131+
targetMeta.isEmpty() -> fullUrl // empty meta = reload same page
132+
targetMeta.startsWith("/") -> {
133+
// normalize path, remove duplicate slashes
134+
val cleanPath = targetMeta.replace(Regex("/{2,}"), "/")
135+
val base = URL(fullUrl)
136+
"gemini://${base.host}$cleanPath"
137+
}
138+
!targetMeta.contains("://") -> {
139+
// no scheme provided, assume gemini://
140+
"gemini://${targetMeta}"
141+
}
142+
else -> {
143+
// absolute URL
144+
targetMeta
145+
}
146+
}
147+
148+
// ensure no trailing slashes if needed (optional)
149+
parsedRedirect.trimEnd('/')
150+
151+
} catch (e: Exception) {
152+
return PageData(
153+
statusCode = -1,
154+
meta = meta,
155+
content = null,
156+
errorMessage = "Error: Invalid redirect URL",
157+
statusMessage = "Invalid Redirect"
158+
)
159+
}
160+
161+
val normalizedRedirectUrl = redirectUrl.trimEnd('/')
162+
val normalizedCurrentUrl = fullUrl.trimEnd('/')
163+
164+
if (normalizedRedirectUrl == normalizedCurrentUrl && redirectCount > 0) {
165+
// only error if we are already in a redirect cycle
166+
return PageData(
167+
statusCode = -1,
168+
meta = meta,
169+
content = null,
170+
errorMessage = "Error: Redirect to same URL repeatedly.",
171+
statusMessage = "Redirect Loop"
172+
)
173+
}
174+
175+
return fetch(redirectUrl, context, redirectCount + 1)
176+
}
177+
118178
else -> PageData(
119179
statusCode = statusCode,
120180
meta = meta,
@@ -133,7 +193,7 @@ object GeminiClient {
133193
)
134194
} catch (e: Exception) {
135195
e.printStackTrace()
136-
return PageData(
196+
PageData(
137197
statusCode = -1,
138198
meta = "",
139199
content = null,
@@ -144,9 +204,7 @@ object GeminiClient {
144204
}
145205

146206
private class SelfSignedTrustManager(private val certificateStorage: CertificateStorage) : X509TrustManager {
147-
148207
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
149-
150208
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
151209
if (chain == null || chain.isEmpty()) {
152210
throw CertificateException("No certificates provided by the server.")
@@ -173,4 +231,4 @@ object GeminiClient {
173231
return hash.joinToString(":") { "%02x".format(it) }
174232
}
175233
}
176-
}
234+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package dev.parham.zapri.protocol.gemini
2+
3+
import java.net.URL
4+
5+
object GeminiParser {
6+
7+
fun parse(content: String, baseUrl: String): List<GemtextElement> {
8+
val lines = content.replace("\r\n", "\n").lines()
9+
val elements = mutableListOf<GemtextElement>()
10+
11+
val base = try {
12+
URL(baseUrl)
13+
} catch (e: Exception) {
14+
null
15+
}
16+
17+
val linesIterator = lines.iterator()
18+
while (linesIterator.hasNext()) {
19+
val line = linesIterator.next()
20+
when {
21+
line.startsWith("=>") -> {
22+
val parts = line.substring(2).trim().replace("\t", " ").split(" ", limit = 2)
23+
val rawUrl = parts[0].trim()
24+
val description = parts.getOrNull(1)?.trim() ?: rawUrl
25+
26+
val resolvedUrl = try {
27+
base?.let { URL(it, rawUrl).toString() } ?: rawUrl
28+
} catch (e: Exception) {
29+
rawUrl
30+
}
31+
32+
elements.add(GemtextElement.Link(resolvedUrl, description))
33+
}
34+
line.startsWith("### ") -> elements.add(GemtextElement.Heading(line.substring(4), level = 3))
35+
line.startsWith("## ") -> elements.add(GemtextElement.Heading(line.substring(3), level = 2))
36+
line.startsWith("# ") -> elements.add(GemtextElement.Heading(line.substring(2), level = 1))
37+
line.startsWith("```") -> {
38+
val preformattedHeader = line.substring(3).trim()
39+
val lang = preformattedHeader.takeIf { it.isNotBlank() }
40+
val preformattedContent = StringBuilder()
41+
while (linesIterator.hasNext()) {
42+
val nextLine = linesIterator.next()
43+
if (nextLine.startsWith("```")) break
44+
preformattedContent.appendLine(nextLine)
45+
}
46+
elements.add(GemtextElement.Preformatted(preformattedContent.toString().trim(), preformattedHeader, lang))
47+
}
48+
line.startsWith("* ") -> elements.add(GemtextElement.ListItem(line.substring(2).trim()))
49+
line.startsWith("> ") -> elements.add(GemtextElement.Quote(line.substring(2).trim()))
50+
line.isBlank() -> elements.add(GemtextElement.EmptyLine)
51+
line.length > 1024 -> elements.add(GemtextElement.Text(line.substring(0, 1024)))
52+
else -> elements.add(GemtextElement.Text(line))
53+
}
54+
}
55+
56+
return elements
57+
}
58+
}

0 commit comments

Comments
 (0)