Compare commits

...

2 commits

Author SHA1 Message Date
dece bfa2285607 improve the default rendering for all elements 2021-12-11 01:26:12 +01:00
dece c11ea868fe 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-10 23:50:18 +01:00
19 changed files with 204 additions and 72 deletions

View file

@ -25,6 +25,7 @@ android {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.debug
}
}
compileOptions {
@ -42,6 +43,7 @@ dependencies {
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'
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,5 +1,6 @@
package dev.lowrespalmtree.comet
import android.annotation.SuppressLint
import android.text.SpannableString
import android.text.style.UnderlineSpan
import android.util.Log
@ -12,6 +13,8 @@ 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)
}
@ -35,7 +38,6 @@ class ContentAdapter(private var content: List<Line>, private val listener: Cont
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContentViewHolder {
Log.d(TAG, "onCreateViewHolder: type $viewType")
return LayoutInflater.from(parent.context).let {
when (viewType) {
TYPE_EMPTY -> ContentViewHolder.Empty(GemtextEmptyBinding.inflate(it))
@ -53,12 +55,12 @@ class ContentAdapter(private var content: List<Line>, private val listener: Cont
}
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: ContentViewHolder, position: Int) {
Log.d(TAG, "onBindViewHolder: position $position")
val line = content[position]
when (holder) {
is ContentViewHolder.Paragraph -> holder.binding.textView.text =
(line as ParagraphLine).text
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
@ -69,15 +71,40 @@ class ContentAdapter(private var content: List<Line>, private val listener: Cont
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
fun setContent(content: List<Line>) {
this.content = content
notifyDataSetChanged()
/**
* 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) {

View file

@ -41,6 +41,7 @@ fun parseData(
channel.send(line)
}
}
channel.close()
}
return channel
}

View file

@ -2,6 +2,7 @@ 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
@ -18,6 +19,7 @@ import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.LinearLayoutManager
import dev.lowrespalmtree.comet.databinding.ActivityMainBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.net.UnknownHostException
import java.nio.charset.Charset
@ -27,6 +29,8 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
private lateinit var pageViewModel: PageViewModel
private lateinit var adapter: ContentAdapter
private val currentUrl get() = binding.addressBar.text
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -50,8 +54,11 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
}
}
pageViewModel.linesLiveData.observe(this, { adapter.setContent(it) })
pageViewModel.alertLiveData.observe(this, { alert(it) })
binding.contentSwipeLayout.setOnRefreshListener { openUrl(currentUrl.toString()) }
pageViewModel.state.observe(this, { updateState(it) })
pageViewModel.lines.observe(this, { updateLines(it) })
pageViewModel.alert.observe(this, { alert(it) })
}
override fun onLinkClick(url: String) {
@ -63,29 +70,68 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
var uri = Uri.parse(url)
if (!uri.isAbsolute) {
uri = if (!base.isNullOrEmpty()) joinUrls(base, url) else toGeminiUri(uri)
Log.d(TAG, "openUrl: '$url' - '$base' - '$uri'")
Log.d(TAG, "openUrl: ${uri.authority} - ${uri.path} - ${uri.query}")
binding.addressBar.setText(uri.toString())
}
when (uri.scheme) {
"gemini" -> pageViewModel.sendGeminiRequest(uri)
else -> startActivity(Intent(ACTION_VIEW, uri))
"gemini" -> {
binding.addressBar.setText(uri.toString())
pageViewModel.sendGeminiRequest(uri)
}
else -> openUnknownScheme(uri)
}
}
private fun alert(message: String) {
AlertDialog.Builder(this)
.setTitle(R.string.alert_title)
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 alert(message: String, title: String? = null) {
val builder = AlertDialog.Builder(this)
if (title != null)
builder.setTitle(title)
else
builder.setTitle(title ?: R.string.alert_title)
builder
.setMessage(message)
.create()
.show()
}
private fun openUnknownScheme(uri: Uri) {
try {
startActivity(Intent(ACTION_VIEW, uri))
} catch (e: ActivityNotFoundException) {
alert("Can't open this URL.")
}
}
class PageViewModel : ViewModel() {
private var lines = ArrayList<Line>()
val linesLiveData: MutableLiveData<List<Line>> by lazy { MutableLiveData<List<Line>>() }
val alertLiveData: MutableLiveData<String> by lazy { MutableLiveData<String>() }
private var requestJob: Job? = null
val state: MutableLiveData<State> by lazy { MutableLiveData<State>(State.IDLE) }
private var linesList = ArrayList<Line>()
val lines: MutableLiveData<List<Line>> by lazy { MutableLiveData<List<Line>>() }
val alert: MutableLiveData<String> by lazy { MutableLiveData<String>() }
enum class State {
IDLE, CONNECTING, RECEIVING
}
/**
* Perform a request against this URI.
@ -93,42 +139,57 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
* The URI must be valid, absolute and with a gemini scheme.
*/
fun sendGeminiRequest(uri: Uri) {
Log.d(TAG, "sendRequest: $uri")
viewModelScope.launch(Dispatchers.IO) {
Log.d(TAG, "sendRequest: URI \"$uri\"")
state.postValue(State.CONNECTING)
requestJob?.apply { if (isActive) cancel() }
requestJob = viewModelScope.launch(Dispatchers.IO) {
val response = try {
val request = Request(uri)
val socket = request.connect()
val channel = request.proceed(socket, this)
Response.from(channel, viewModelScope)
} catch (e: UnknownHostException) {
alertLiveData.postValue("Unknown host \"${uri.authority}\".")
signalError("Unknown host \"${uri.authority}\".")
return@launch
} catch (e: Exception) {
Log.e(TAG, "sendGeminiRequest coroutine: ${e.stackTraceToString()}")
alertLiveData.postValue("Oops! Whatever we tried to do failed!")
signalError("Oops! Whatever we tried to do failed!")
return@launch
}
if (response == null) {
alertLiveData.postValue("Can't parse server response.")
signalError("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}.")
else -> signalError("Can't handle code ${response.code}.")
}
}
}
private fun signalError(message: String) {
alert.postValue(message)
state.postValue(State.IDLE)
}
private suspend fun handleRequestSuccess(response: Response) {
lines.clear()
state.postValue(State.RECEIVING)
linesList.clear()
lines.postValue(linesList)
val charset = Charset.defaultCharset()
var lastUpdate = System.currentTimeMillis()
for (line in parseData(response.data, charset, viewModelScope)) {
lines.add(line)
linesLiveData.postValue(lines)
linesList.add(line)
val time = System.currentTimeMillis()
if (time - lastUpdate >= 100) { // Throttle to 100ms the recycler view updates…
lines.postValue(linesList)
lastUpdate = time
}
}
lines.postValue(linesList)
state.postValue(State.IDLE)
}
}

View file

@ -50,6 +50,7 @@ class Request(private val uri: Uri) {
}
}
Log.d(TAG, "proceed coroutine: reading completed")
channel.close()
}
return channel
}

View file

@ -70,6 +70,7 @@ class Response(val code: Code, val meta: String, val data: Channel<ByteArray>) {
}
// Forward all incoming data to the Response channel.
channel.consumeEach { responseChannel.send(it) }
responseChannel.close()
}
// Return the response here; this stops consuming the channel from this for-loop so
// that the coroutine above can take care of it.

View file

@ -24,28 +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="wrap_content"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/content_recycler"
<RelativeLayout
android:id="@+id/content_inner_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" />
</androidx.core.widget.NestedScrollView>
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/content_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent" />
<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

@ -1,6 +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:background="@color/teal_200" />
android:paddingStart="32dp"
android:paddingEnd="16dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />

View file

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

View file

@ -2,11 +2,11 @@
<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="8dp"
android:paddingEnd="8dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:background="@color/teal_200"
android:textIsSelectable="false" />

View file

@ -1,6 +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:background="@color/teal_200" />
android:paddingStart="32dp"
android:paddingEnd="16dp"
android:paddingTop="2dp"
android:paddingBottom="2dp" />

View file

@ -2,10 +2,10 @@
<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="8dp"
android:paddingEnd="8dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:background="@color/teal_200" />
android:paddingBottom="4dp" />

View file

@ -1,6 +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:background="@color/teal_200" />
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:background="@color/purple_200" />

View file

@ -1,12 +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:textSize="30sp"
android:textStyle="bold"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="16dp"
android:paddingBottom="4dp"
android:background="@color/teal_700" />
android:paddingBottom="4dp" />

View file

@ -1,12 +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:textSize="24sp"
android:textStyle="bold"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="4dp"
android:background="@color/teal_700" />
android:paddingBottom="4dp" />

View file

@ -1,12 +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:textSize="18sp"
android:textStyle="bold"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="4dp"
android:background="@color/teal_700" />
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

@ -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>