Compare commits

...

15 commits

Author SHA1 Message Date
dece db321c31f1 MainActivity: proper alerts for each error type
Also moved the PageViewModel into its own file? What's the norm?
2021-12-13 23:36:52 +01:00
dece a2dd6f4876 improve the network channels for streaming 2021-12-13 23:36:52 +01:00
dece f8b929b00d MainActivity: show user-friendly error messages 2021-12-13 23:36:52 +01:00
dece a6e910c3fa MainActivity: handle redirections 2021-12-13 23:36:52 +01:00
dece f8cfe2ad09 Gemtext: just trim most whitespaces for simplicity 2021-12-13 23:36:52 +01:00
dece 44629dd3ad ContentAdapter: renamed for consistency 2021-12-13 23:36:52 +01:00
dece a44c36bdca res: reduce a bit the vertical paddings 2021-12-13 23:36:52 +01:00
dece 9743fea7d5 MainActivity: fix issue with going back 2021-12-13 23:36:52 +01:00
dece 6a65df0f4e Gemtext: fix issue with link parsing 2021-12-13 23:36:52 +01:00
dece 51f822eb9d History: add Room database for history
Also add going back support.
2021-12-13 23:36:52 +01:00
dece 5cb3af8d12 improve the default rendering for all elements 2021-12-13 23:36:52 +01:00
dece 3cba46ad5d MainActivity: rework layout to avoid lag
ScrollView + RecyclerView lead to very slow loading times for the
recycler, like a few seconds on the biggest Medusae pages. Turns out it
binds every ViewHolder instantly, losing all the recycling behavior!
Following some guidelines on StackOverflow fixed this, because official
docs could not.
2021-12-13 23:36:52 +01:00
dece 57854e56dc UriUtils: add a bunch of URI utilities
So we can now click on relative links, wow! Fast progress!
2021-12-13 23:36:52 +01:00
dece e540cf7628 ContentRecycler: add awful recycler for content
Currently exploding the amount of views to use because recyclers are NOT
fun to use.
2021-12-13 23:36:52 +01:00
dece f2eae54234 Gemtext: add a sweet streaming parser 2021-12-13 23:36:48 +01:00
26 changed files with 732 additions and 147 deletions

View file

@ -1,6 +1,7 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
}
android {
@ -18,10 +19,14 @@ android {
buildFeatures {
viewBinding true
}
testOptions {
unitTests.returnDefaultValues = true
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.debug
}
}
compileOptions {
@ -36,9 +41,12 @@ android {
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0"
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation "androidx.room:room-runtime:2.3.0"
implementation 'com.google.android.material:material:1.4.0'
kapt "androidx.room:room-compiler:2.3.0"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'

View file

@ -1,24 +0,0 @@
package dev.lowrespalmtree.comet
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("dev.lowrespalmtree.comet", appContext.packageName)
}
}

View file

@ -0,0 +1,76 @@
package dev.lowrespalmtree.comet
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class UriUtilsTest {
@Test
fun joinUrls() {
assertEquals(
"gemini://dece.space/some-file.gmi",
joinUrls("gemini://dece.space/", "some-file.gmi").toString()
)
assertEquals(
"gemini://dece.space/some-file.gmi",
joinUrls("gemini://dece.space/", "./some-file.gmi").toString()
)
assertEquals(
"gemini://dece.space/some-file.gmi",
joinUrls("gemini://dece.space/dir1", "/some-file.gmi").toString()
)
assertEquals(
"gemini://dece.space/dir1/other-file.gmi",
joinUrls("gemini://dece.space/dir1/file.gmi", "other-file.gmi").toString()
)
assertEquals(
"gemini://dece.space/top-level.gmi",
joinUrls("gemini://dece.space/dir1/file.gmi", "../top-level.gmi").toString()
)
assertEquals(
"s://hard/test/b/d/a.gmi",
joinUrls("s://hard/dir/a", "./../test/b/c/../d/e/f/../.././a.gmi").toString()
)
}
@Test
fun removeDotSegments() {
arrayOf(
Pair("index.gmi", "index.gmi"),
Pair("/index.gmi", "/index.gmi"),
Pair("./index.gmi", "index.gmi"),
Pair("/./index.gmi", "/index.gmi"),
Pair("/../index.gmi", "/index.gmi"),
Pair("/a/b/c/./../../g", "/a/g"),
Pair("mid/content=5/../6", "mid/6"),
Pair("../../../../g", "g")
).forEach { (path, expected) ->
assertEquals(expected, removeDotSegments(path))
}
}
@Test
fun removeLastSegment() {
assertEquals("", removeLastSegment(""))
assertEquals("", removeLastSegment("/"))
assertEquals("", removeLastSegment("/a"))
assertEquals("/a", removeLastSegment("/a/"))
assertEquals("/a", removeLastSegment("/a/b"))
assertEquals("/a/b/c", removeLastSegment("/a/b/c/d"))
assertEquals("//", removeLastSegment("///"))
}
@Test
fun popFirstSegment() {
assertEquals(Pair("", ""), popFirstSegment(""))
assertEquals(Pair("a", ""), popFirstSegment("a"))
assertEquals(Pair("/a", ""), popFirstSegment("/a"))
assertEquals(Pair("/a", "/"), popFirstSegment("/a/"))
assertEquals(Pair("/a", "/b"), popFirstSegment("/a/b"))
assertEquals(Pair("a", "/b"), popFirstSegment("a/b"))
}
}

View file

@ -0,0 +1,136 @@
package dev.lowrespalmtree.comet
import android.annotation.SuppressLint
import android.text.SpannableString
import android.text.style.UnderlineSpan
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import dev.lowrespalmtree.comet.databinding.*
class ContentAdapter(private var content: List<Line>, private val listener: ContentAdapterListen) :
RecyclerView.Adapter<ContentAdapter.ContentViewHolder>() {
private var lastLineCount = 0
interface ContentAdapterListen {
fun onLinkClick(url: String)
}
override fun getItemViewType(position: Int): Int =
when (content[position]) {
is EmptyLine -> TYPE_EMPTY
is ParagraphLine -> TYPE_PARAGRAPH
is LinkLine -> TYPE_LINK
is PreFenceLine -> TYPE_PRE_FENCE
is PreTextLine -> TYPE_PRE_TEXT
is BlockquoteLine -> TYPE_BLOCKQUOTE
is ListItemLine -> TYPE_LIST_ITEM
is TitleLine -> when ((content[position] as TitleLine).level) {
1 -> TYPE_TITLE_1
2 -> TYPE_TITLE_2
3 -> TYPE_TITLE_3
else -> error("invalid title level")
}
else -> error("unknown line type")
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContentViewHolder {
return LayoutInflater.from(parent.context).let {
when (viewType) {
TYPE_EMPTY -> ContentViewHolder.Empty(GemtextEmptyBinding.inflate(it))
TYPE_PARAGRAPH -> ContentViewHolder.Paragraph(GemtextParagraphBinding.inflate(it))
TYPE_TITLE_1 -> ContentViewHolder.Title1(GemtextTitle1Binding.inflate(it))
TYPE_TITLE_2 -> ContentViewHolder.Title2(GemtextTitle2Binding.inflate(it))
TYPE_TITLE_3 -> ContentViewHolder.Title3(GemtextTitle3Binding.inflate(it))
TYPE_LINK -> ContentViewHolder.Link(GemtextLinkBinding.inflate(it))
TYPE_PRE_FENCE -> ContentViewHolder.PreFence(GemtextEmptyBinding.inflate(it))
TYPE_PRE_TEXT -> ContentViewHolder.PreText(GemtextPreformattedBinding.inflate(it))
TYPE_BLOCKQUOTE -> ContentViewHolder.Blockquote(GemtextBlockquoteBinding.inflate(it))
TYPE_LIST_ITEM -> ContentViewHolder.ListItem(GemtextListItemBinding.inflate(it))
else -> error("invalid view type")
}
}
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: ContentViewHolder, position: Int) {
val line = content[position]
when (holder) {
is ContentViewHolder.Paragraph ->
holder.binding.textView.text = (line as ParagraphLine).text
is ContentViewHolder.Title1 -> holder.binding.textView.text = (line as TitleLine).text
is ContentViewHolder.Title2 -> holder.binding.textView.text = (line as TitleLine).text
is ContentViewHolder.Title3 -> holder.binding.textView.text = (line as TitleLine).text
is ContentViewHolder.Link -> {
val text = if ((line as LinkLine).label.isNotEmpty()) line.label else line.url
val underlined = SpannableString(text)
underlined.setSpan(UnderlineSpan(), 0, underlined.length, 0)
holder.binding.textView.text = underlined
holder.binding.root.setOnClickListener { listener.onLinkClick(line.url) }
}
is ContentViewHolder.PreText ->
holder.binding.textView.text = (line as PreTextLine).text
is ContentViewHolder.Blockquote ->
holder.binding.textView.text = (line as BlockquoteLine).text
is ContentViewHolder.ListItem ->
holder.binding.textView.text = "\u25CF ${(line as ListItemLine).text}"
else -> {}
}
}
override fun getItemCount(): Int = content.size
/**
* Replace the content rendered by the recycler.
*
* The new content list may or may not be the same object as the previous one, we don't
* assume anything. The assumptions this function do however are:
* - If the new content is empty, we are about to load new content, so clear the recycler.
* - If it's longer than before, we received new streamed content, so *append* data.
* - If it's shorter or the same size than before, we do not notify anything and let the caller
* manage the changes itself.
*/
@SuppressLint("NotifyDataSetChanged")
fun setContent(newContent: List<Line>) {
content = newContent.toList() // Shallow copy to avoid concurrent update issues.
if (content.isEmpty()) {
Log.d(TAG, "setContent: empty content")
notifyDataSetChanged()
} else if (content.size > lastLineCount) {
val numAdded = content.size - lastLineCount
Log.d(TAG, "setContent: added $numAdded items")
notifyItemRangeInserted(lastLineCount, numAdded)
}
lastLineCount = content.size
}
sealed class ContentViewHolder(view: View) : RecyclerView.ViewHolder(view) {
class Empty(val binding: GemtextEmptyBinding) : ContentViewHolder(binding.root)
class Paragraph(val binding: GemtextParagraphBinding) : ContentViewHolder(binding.root)
class Title1(val binding: GemtextTitle1Binding) : ContentViewHolder(binding.root)
class Title2(val binding: GemtextTitle2Binding) : ContentViewHolder(binding.root)
class Title3(val binding: GemtextTitle3Binding) : ContentViewHolder(binding.root)
class Link(val binding: GemtextLinkBinding) : ContentViewHolder(binding.root)
class PreFence(val binding: GemtextEmptyBinding) : ContentViewHolder(binding.root)
class PreText(val binding: GemtextPreformattedBinding) : ContentViewHolder(binding.root)
class Blockquote(val binding: GemtextBlockquoteBinding) : ContentViewHolder(binding.root)
class ListItem(val binding: GemtextListItemBinding) : ContentViewHolder(binding.root)
}
companion object {
const val TAG = "ContentRecycler"
const val TYPE_EMPTY = 0
const val TYPE_PARAGRAPH = 1
const val TYPE_TITLE_1 = 2
const val TYPE_TITLE_2 = 3
const val TYPE_TITLE_3 = 4
const val TYPE_LINK = 5
const val TYPE_PRE_FENCE = 6
const val TYPE_PRE_TEXT = 7
const val TYPE_BLOCKQUOTE = 8
const val TYPE_LIST_ITEM = 9
}
}

View file

@ -0,0 +1,21 @@
package dev.lowrespalmtree.comet
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [History.HistoryEntry::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun historyEntryDao(): History.HistoryEntryDao
}
object Database {
lateinit var INSTANCE: AppDatabase
fun init(context: Context) {
if (::INSTANCE.isInitialized)
return
INSTANCE = Room.databaseBuilder(context, AppDatabase::class.java, "comet").build()
}
}

View file

@ -0,0 +1,69 @@
package dev.lowrespalmtree.comet
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import java.nio.ByteBuffer
import java.nio.CharBuffer
import java.nio.charset.Charset
interface Line
class EmptyLine : Line
class ParagraphLine(val text: String) : Line
class TitleLine(val level: Int, val text: String) : Line
class LinkLine(val url: String, val label: String) : Line
class PreFenceLine(val caption: String) : Line
class PreTextLine(val text: String) : Line
class BlockquoteLine(val text: String) : Line
class ListItemLine(val text: String) : Line
const val TAG = "Gemtext"
fun parseData(
inChannel: Channel<ByteArray>,
charset: Charset,
scope: CoroutineScope
): Channel<Line> {
val channel = Channel<Line>()
scope.launch {
var isPref = false
var buffer = ByteArray(0)
Log.d(TAG, "parseData: start getting data from channel")
for (data in inChannel) {
buffer += data
var nextLineFeed: Int = -1
while (buffer.isNotEmpty() && buffer.indexOf(0x0A).also { nextLineFeed = it } > -1) {
val lineData = buffer.sliceArray(0 until nextLineFeed)
buffer = buffer.sliceArray(nextLineFeed + 1 until buffer.size)
val lineString = charset.decode(ByteBuffer.wrap(lineData))
val line = parseLine(lineString, isPref)
when (line) {
is PreFenceLine -> isPref = !isPref
}
channel.send(line)
}
}
Log.d(TAG, "parseData: channel closed")
channel.close()
}
return channel
}
private fun parseLine(line: CharBuffer, isPreformatted: Boolean): Line =
when {
line.isEmpty() -> EmptyLine()
line.startsWith("###") -> TitleLine(3, getCharsFrom(line, 3))
line.startsWith("##") -> TitleLine(2, getCharsFrom(line, 2))
line.startsWith("#") -> TitleLine(1, getCharsFrom(line, 1))
line.startsWith(">") -> BlockquoteLine(getCharsFrom(line, 1))
line.startsWith("```") -> PreFenceLine(getCharsFrom(line, 3))
line.startsWith("* ") -> ListItemLine(getCharsFrom(line, 2))
line.startsWith("=>") -> getCharsFrom(line, 2)
.split(" ", limit = 2)
.run { LinkLine(get(0), if (size == 2) get(1).trimStart() else "") }
else -> if (isPreformatted) PreTextLine(line.toString()) else ParagraphLine(line.toString())
}
private fun getCharsFrom(line: CharBuffer, index: Int) = line.substring(index).trim()

View file

@ -0,0 +1,37 @@
package dev.lowrespalmtree.comet
import androidx.room.*
object History {
@Entity
data class HistoryEntry(
@PrimaryKey @ColumnInfo(name = "uri") val uri: String,
@ColumnInfo(name = "title") var title: String?,
@ColumnInfo(name = "lastVisit") var lastVisit: Long,
)
@Dao
interface HistoryEntryDao {
@Query("SELECT * FROM HistoryEntry WHERE :uri = uri LIMIT 1")
fun get(uri: String): HistoryEntry?
@Query("SELECT * FROM HistoryEntry ORDER BY lastVisit DESC")
fun getAll(): List<HistoryEntry>
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(vararg entries: HistoryEntry)
@Update
fun update(vararg entries: HistoryEntry)
}
fun record(uri: String, title: String?) {
val now = System.currentTimeMillis()
val dao = Database.INSTANCE.historyEntryDao()
val entry = dao.get(uri)
if (entry == null)
dao.insert(HistoryEntry(uri, title, now))
else
dao.update(entry.also { it.title = title; it.lastVisit = now })
}
}

View file

@ -1,7 +1,9 @@
package dev.lowrespalmtree.comet
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.Intent.ACTION_VIEW
import android.net.Uri
import android.os.Bundle
import android.util.Log
@ -9,31 +11,41 @@ import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.LinearLayoutManager
import dev.lowrespalmtree.comet.databinding.ActivityMainBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.nio.ByteBuffer
import java.nio.charset.Charset
import kotlinx.coroutines.ExperimentalCoroutinesApi
class MainActivity : AppCompatActivity() {
@ExperimentalCoroutinesApi
class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
private lateinit var binding: ActivityMainBinding
private lateinit var pageViewModel: PageViewModel
private lateinit var adapter: ContentAdapter
/** Property to access and set the current address bar URL value. */
private var currentUrl
get() = binding.addressBar.text.toString()
set(value) = binding.addressBar.setText(value)
/** A non-saved list of visited URLs. Not an history, just used for going back. */
private val visitedUrls = mutableListOf<String>()
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Database.init(applicationContext)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
pageViewModel = ViewModelProvider(this)[PageViewModel::class.java]
adapter = ContentAdapter(listOf(), this)
binding.contentRecycler.layoutManager = LinearLayoutManager(this)
binding.contentRecycler.adapter = adapter
binding.addressBar.setOnEditorActionListener { view, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
pageViewModel.sendRequest(view.text.toString())
openUrl(view.text.toString())
val imm = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
view.clearFocus()
@ -43,57 +55,99 @@ class MainActivity : AppCompatActivity() {
}
}
pageViewModel.sourceLiveData.observe(this, {
binding.sourceBlock.text = it
})
pageViewModel.alertLiveData.observe(this, {
AlertDialog.Builder(this)
.setTitle(R.string.alert_title)
.setMessage(it)
.create()
.show()
})
binding.contentSwipeLayout.setOnRefreshListener { openUrl(currentUrl) }
pageViewModel.state.observe(this, { updateState(it) })
pageViewModel.lines.observe(this, { updateLines(it) })
pageViewModel.event.observe(this, { handleEvent(it) })
}
class PageViewModel : ViewModel() {
var source = ""
val sourceLiveData: MutableLiveData<String> by lazy { MutableLiveData<String>() }
val alertLiveData: MutableLiveData<String> by lazy { MutableLiveData<String>() }
override fun onBackPressed() {
visitedUrls.removeLastOrNull() // Always remove current page first.
val previousUrl = visitedUrls.removeLastOrNull()
if (previousUrl != null)
openUrl(previousUrl)
else
super.onBackPressed()
}
fun sendRequest(url: String) {
Log.d(TAG, "sendRequest: $url")
source = ""
viewModelScope.launch(Dispatchers.IO) {
val uri = Uri.parse(url)
if (uri.scheme != "gemini") {
alertLiveData.postValue("Can't process scheme \"${uri.scheme}\".")
return@launch
}
override fun onLinkClick(url: String) {
openUrl(url, base = if (currentUrl.isNotEmpty()) currentUrl else null)
}
val request = Request(uri)
val socket = request.connect()
val channel = request.proceed(socket, this)
val response = Response.from(channel, viewModelScope)
if (response == null) {
alertLiveData.postValue("Can't parse server response.")
return@launch
}
Log.i(TAG, "sendRequest: got ${response.code} with meta \"${response.meta}\"")
when (response.code) {
Response.Code.SUCCESS -> handleRequestSuccess(response)
else -> alertLiveData.postValue("Can't handle code ${response.code}.")
}
}
private fun openUrl(url: String, base: String? = null, redirections: Int = 0) {
if (redirections >= 5) {
alert("Too many redirections.")
return
}
private suspend fun handleRequestSuccess(response: Response) {
val charset = Charset.defaultCharset()
for (data in response.data) {
val decoded = charset.decode(ByteBuffer.wrap(data)).toString()
source += decoded
sourceLiveData.postValue(source)
var uri = Uri.parse(url)
if (!uri.isAbsolute) {
uri = if (!base.isNullOrEmpty()) joinUrls(base, url) else toGeminiUri(uri)
}
when (uri.scheme) {
"gemini" -> pageViewModel.sendGeminiRequest(uri)
else -> openUnknownScheme(uri)
}
}
private fun updateState(state: PageViewModel.State) {
Log.d(TAG, "updateState: $state")
when (state) {
PageViewModel.State.IDLE -> {
binding.contentProgressBar.hide()
binding.contentSwipeLayout.isRefreshing = false
}
PageViewModel.State.CONNECTING -> {
binding.contentProgressBar.show()
}
PageViewModel.State.RECEIVING -> {
binding.contentSwipeLayout.isRefreshing = false
}
}
}
private fun updateLines(lines: List<Line>) {
Log.d(TAG, "updateLines: ${lines.size} lines")
adapter.setContent(lines)
}
private fun handleEvent(event: PageViewModel.Event) {
Log.d(TAG, "handleEvent: $event")
if (!event.handled) {
when (event) {
is PageViewModel.SuccessEvent -> {
currentUrl = event.uri
visitedUrls.add(event.uri)
}
is PageViewModel.RedirectEvent -> {
openUrl(event.uri, base = currentUrl, redirections = event.redirects)
}
is PageViewModel.FailureEvent -> {
var message = event.details
if (!event.serverDetails.isNullOrEmpty())
message += "\n\n" + "Server details: ${event.serverDetails}"
alert(message, title = event.short)
}
}
event.handled = true
}
}
private fun alert(message: String, title: String? = null) {
AlertDialog.Builder(this)
.setTitle(title ?: getString(R.string.error_alert_title))
.setMessage(message)
.create()
.show()
}
private fun openUnknownScheme(uri: Uri) {
try {
startActivity(Intent(ACTION_VIEW, uri))
} catch (e: ActivityNotFoundException) {
alert("Can't open this URL.")
}
}

View file

@ -7,6 +7,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import java.io.BufferedInputStream
import java.net.InetSocketAddress
import java.net.SocketTimeoutException
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
@ -20,8 +21,9 @@ class Request(private val uri: Uri) {
Log.d(TAG, "connect")
val context = SSLContext.getInstance("TLSv1.2")
context.init(null, arrayOf(TrustManager()), null)
val socket = context.socketFactory.createSocket(uri.host, port) as SSLSocket
val socket = context.socketFactory.createSocket() as SSLSocket
socket.soTimeout = 10000
socket.connect(InetSocketAddress(uri.host, port), 10000)
socket.startHandshake()
return socket
}
@ -39,25 +41,25 @@ class Request(private val uri: Uri) {
try {
@Suppress("BlockingMethodInNonBlockingContext") // what u gonna do
while ((bis.read(buffer).also { numRead = it }) >= 0) {
Log.d(TAG, "proceed coroutine: received $numRead bytes")
val received = buffer.sliceArray(0 until numRead)
channel.send(received)
}
} catch (e: SocketTimeoutException) {
Log.i(TAG, "Socket timeout.")
channel.cancel()
Log.i(TAG, "proceed coroutine: socket timeout.")
}
}
}
Log.d(TAG, "proceed coroutine: reading completed")
channel.close()
}
return channel
}
@SuppressLint("CustomX509TrustManager")
class TrustManager: X509TrustManager {
class TrustManager : X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
Log.d(TAG, "cool cert, please continue")

View file

@ -3,7 +3,7 @@ package dev.lowrespalmtree.comet
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.launch
import java.nio.ByteBuffer
import java.nio.charset.Charset
@ -31,6 +31,18 @@ class Response(val code: Code, val meta: String, val data: Channel<ByteArray>) {
CERTIFICATE_NOT_AUTHORISED(61),
CERTIFICATE_NOT_VALID(62);
enum class Category(val value: Int) {
UNKNOWN(0),
INPUT(1),
SUCCESS(2),
REDIRECT(3),
SERVER_ERROR(4),
CLIENT_ERROR(5),
CERTIFICATE(6);
}
fun getCategory(): Category? = Category.values().associateBy(Category::value)[value / 10]
companion object {
private val MAP = values().associateBy(Code::value)
fun fromInt(type: Int) = MAP[type]
@ -39,43 +51,56 @@ class Response(val code: Code, val meta: String, val data: Channel<ByteArray>) {
companion object {
private const val TAG = "Response"
private const val MAX_META_LEN = 1024
private const val MAX_HEADER_LEN = 2 + 1 + MAX_META_LEN + 2
/** Return a response object from the incoming server data, served through the channel. */
suspend fun from(channel: Channel<ByteArray>, scope: CoroutineScope): Response? {
var received = 0
val headerBuffer = ByteBuffer.allocate(1024)
for (data in channel) {
var lfIndex: Int
// While we don't have a response object (i.e. no header parsed), keep reading.
while (true) {
val data = try {
channel.receive()
} catch (e: ClosedReceiveChannelException) {
Log.i(TAG, "companion from: channel closed during initial receive")
return null
}
if (received + data.size > MAX_HEADER_LEN) {
Log.i(TAG, "companion from: received too much data for a valid header")
return null
}
// Push some data into our buffer.
headerBuffer.put(data)
received += data.size
// Check if there is enough data to parse a Gemini header from it (e.g. has \r\n).
val lfIndex = headerBuffer.array().indexOf(0x0D) // \r
lfIndex = headerBuffer.array().indexOf(0x0D) // \r
if (lfIndex == -1)
continue
if (headerBuffer.array()[lfIndex + 1] != (0x0A.toByte())) // \n
continue
// We have our header! Parse it to create our Response object.
val bytes = headerBuffer.array()
val headerData = bytes.sliceArray(0 until lfIndex)
val (code, meta) = parseHeader(headerData)
?: return null.also { Log.e(TAG, "Failed to parse header") }
val responseChannel = Channel<ByteArray>()
val response = Response(code, meta, responseChannel)
scope.launch {
// If we got too much data from the channel: push the trailing data first.
val trailingIndex = lfIndex + 2
if (trailingIndex < received) {
val trailingData = bytes.sliceArray(trailingIndex until received)
responseChannel.send(trailingData)
}
// Forward all incoming data to the Response channel.
channel.consumeEach { responseChannel.send(it) }
}
// Return the response here; this stops consuming the channel from this for-loop so
// that the coroutine above can take care of it.
return response
break
}
return null
// We have our header! Parse it to create our Response object.
val bytes = headerBuffer.array()
val headerData = bytes.sliceArray(0 until lfIndex)
val (code, meta) = parseHeader(headerData)
?: return null .also { Log.i(TAG, "companion from: can't parse header") }
val response = Response(code, meta, Channel())
scope.launch {
// If we got too much data from the channel: push the trailing data first.
val trailingIndex = lfIndex + 2
if (trailingIndex < received) {
val trailingData = bytes.sliceArray(trailingIndex until received)
response.data.send(trailingData)
}
// Forward all incoming data to the Response channel.
for (data in channel)
response.data.send(data)
response.data.close()
}
return response
}
/** Return the code and meta from this header if it could be parsed correctly. */

View file

@ -0,0 +1,88 @@
package dev.lowrespalmtree.comet
import android.net.Uri
import android.util.Log
/**
* Transform a relative URI to an absolute Gemini URI
*
* This is mostly to translate user-friendly URLs such as "medusae.space" into
* "gemini://medusae.space". This assumes that the Uri parsing put what the user probably intended
* as the hostname into the path field instead of the authority. Thus, it will NOT merely translate
* any absolute URI into a gemini-scheme URI.
*/
fun toGeminiUri(uri: Uri): Uri =
Uri.Builder()
.scheme("gemini")
.authority(uri.path)
.query(uri.query)
.fragment(uri.fragment)
.build()
/** Return the URI obtained from considering the relative part of an URI wrt/ a base URI. */
fun joinUrls(base: String, relative: String): Uri {
val baseUri = Uri.parse(base)
val relUri = Uri.parse(relative)
val newPath = removeDotSegments(
if (relative.startsWith("/")) {
relUri.path ?: ""
} else {
removeLastSegment(
baseUri.path ?: ""
) + "/" +
(relUri.path ?: "")
}
)
return Uri.Builder()
.scheme(baseUri.scheme)
.authority(baseUri.authority)
.path(newPath)
.query(relUri.query)
.fragment(relUri.fragment)
.build()
}
/** Remove all the sneaky dot segments from the path. */
internal fun removeDotSegments(path: String): String {
var output = ""
var slice = path
while (slice.isNotEmpty()) {
if (slice.startsWith("../")) {
slice = slice.substring(3)
} else if (slice.startsWith("./") || slice.startsWith("/./")) {
slice = slice.substring(2)
} else if (slice == "/.") {
slice = "/"
} else if (slice.startsWith("/../")) {
slice = "/" + slice.substring(4)
output = removeLastSegment(output)
} else if (slice == "/..") {
slice = "/"
output = removeLastSegment(output)
} else if (slice == "." || slice == "..") {
slice = ""
} else {
val (firstSegment, remaining) = popFirstSegment(slice)
output += firstSegment
slice = remaining
}
}
return output
}
/** Remove the last segment of that path, including preceding "/" if any. */
internal fun removeLastSegment(path: String): String {
val lastSlashIndex = path.indexOfLast { it == '/' }
return if (lastSlashIndex > -1) path.slice(0 until lastSlashIndex) else path
}
/** Return first segment and the rest. */
internal fun popFirstSegment(path: String): Pair<String, String> {
if (path.isEmpty())
return Pair(path, "")
var nextSlash = path.substring(1).indexOf("/")
if (nextSlash == -1)
return Pair(path, "")
nextSlash++
return Pair(path.substring(0, nextSlash), path.substring(nextSlash))
}

View file

@ -24,40 +24,48 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="4dp"
android:ems="10"
android:hint="@string/url"
android:imeOptions="actionDone|actionGo"
android:importantForAutofill="no"
android:inputType="textUri"
android:text="" />
android:text=""
tools:ignore="TextContrastCheck" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:id="@+id/scroll_view"
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/content_swipe_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout
<RelativeLayout
android:id="@+id/content_inner_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<TextView
android:id="@+id/source_block"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/content_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:text=""
android:textAlignment="textStart"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<androidx.core.widget.ContentLoadingProgressBar
android:id="@+id/content_progress_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="16dp"
android:layout_alignParentTop="true"
android:layout_marginTop="-8dp"
android:indeterminate="true"
android:visibility="gone" />
</RelativeLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/text_view"
android:textSize="16sp"
android:textColor="@color/text"
android:textStyle="italic"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:paddingEnd="16dp"
android:paddingTop="2dp"
android:paddingBottom="4dp" />

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="4dp" />

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/text_view"
android:textSize="16sp"
android:textColor="@color/teal_700"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="2dp"
android:paddingBottom="4dp"
android:textIsSelectable="false" />

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/text_view"
android:textSize="16sp"
android:textColor="@color/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:paddingEnd="16dp"
android:paddingTop="2dp"
android:paddingBottom="4dp" />

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/text_view"
android:textSize="16sp"
android:textColor="@color/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="2dp"
android:paddingBottom="4dp" />

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/text_view"
android:textSize="14sp"
android:textColor="@color/text"
android:typeface="monospace"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:background="@color/purple_200" />

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/text_view"
android:textSize="30sp"
android:textColor="@color/text"
android:textStyle="bold"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="16dp"
android:paddingBottom="4dp" />

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/text_view"
android:textSize="24sp"
android:textColor="@color/text"
android:textStyle="bold"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="4dp" />

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/text_view"
android:textSize="18sp"
android:textColor="@color/text"
android:textStyle="bold"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="4dp" />

View file

@ -1,6 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Comet" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<style name="Theme.Comet" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>

View file

@ -7,4 +7,5 @@
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="text">#222222</color>
</resources>

View file

@ -1,6 +1,6 @@
<resources>
<string name="app_name">Comet</string>
<string name="alert_title">Alert</string>
<string name="error_alert_title">Error</string>
<string name="action_settings">Settings</string>
<string name="url">URL</string>
</resources>

View file

@ -9,6 +9,8 @@
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -1,17 +0,0 @@
package dev.lowrespalmtree.comet
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}