move page viewer logic into a fragment

This commit is contained in:
dece 2022-01-10 08:41:33 +01:00
parent 6eb9c142cc
commit 09e19a1a76
5 changed files with 298 additions and 250 deletions

View file

@ -19,7 +19,7 @@ import dev.lowrespalmtree.comet.databinding.*
* it could be a n-to-1 relation: many lines belong to the same block. This of course changes a bit
* the way we have to render things.
*/
class ContentAdapter(private val listener: ContentAdapterListen) :
class ContentAdapter(private val listener: ContentAdapterListener) :
RecyclerView.Adapter<ContentAdapter.ContentViewHolder>() {
private var lines = listOf<Line>()
@ -27,7 +27,7 @@ class ContentAdapter(private val listener: ContentAdapterListen) :
private var blocks = mutableListOf<ContentBlock>()
private var lastBlockCount = 0
interface ContentAdapterListen {
interface ContentAdapterListener {
fun onLinkClick(url: String)
}

View file

@ -1,200 +1,31 @@
package dev.lowrespalmtree.comet
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.text.InputType
import android.util.Log
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.FrameLayout.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout.LayoutParams.WRAP_CONTENT
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.setMargins
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import dev.lowrespalmtree.comet.databinding.ActivityMainBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
@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>()
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
Database.init(applicationContext)
Database.init(applicationContext) // TODO move to App Startup?
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
pageViewModel = ViewModelProvider(this)[PageViewModel::class.java]
adapter = ContentAdapter(this)
binding.contentRecycler.layoutManager = LinearLayoutManager(this)
binding.contentRecycler.adapter = adapter
binding.addressBar.setOnEditorActionListener { view, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
openUrl(view.text.toString())
val imm = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
view.clearFocus()
true
} else {
false
if (savedInstanceState == null) {
supportFragmentManager
.beginTransaction()
.add(R.id.main_content, PageViewFragment().apply { arguments = intent.extras })
.commit()
}
}
binding.contentSwipeLayout.setOnRefreshListener { openUrl(currentUrl) }
pageViewModel.state.observe(this, { updateState(it) })
pageViewModel.lines.observe(this, { updateLines(it) })
pageViewModel.event.observe(this, { handleEvent(it) })
}
override fun onBackPressed() {
visitedUrls.removeLastOrNull() // Always remove current page first.
val previousUrl = visitedUrls.removeLastOrNull()
if (previousUrl != null)
openUrl(previousUrl)
else
// TODO pass to PageViewFragment
super.onBackPressed()
}
override fun onLinkClick(url: String) {
openUrl(url, base = if (currentUrl.isNotEmpty()) currentUrl else null)
}
/**
* Open an URL.
*
* This function can be called after the user entered an URL in the app bar, clicked on a link,
* whatever. To make the user's life a bit easier, this function also makes a few guesses:
* - If the URL is not absolute, make it so from a base URL (e.g. the current URL) or assume
* the user only typed a hostname without scheme and use a utility function to make it
* absolute.
* - If it's an absolute Gemini URL with an empty path, use "/" instead as per the spec.
*/
private fun openUrl(url: String, base: String? = null, redirections: Int = 0) {
if (redirections >= 5) {
alert("Too many redirections.")
return
}
var uri = Uri.parse(url)
if (!uri.isAbsolute) {
uri = if (!base.isNullOrEmpty()) joinUrls(base, url) else toGeminiUri(uri)
} else if (uri.scheme == "gemini" && uri.path.isNullOrEmpty()) {
uri = uri.buildUpon().path("/").build()
}
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.appBar.setExpanded(true, true)
binding.contentSwipeLayout.isRefreshing = false
}
}
}
private fun updateLines(lines: List<Line>) {
Log.d(TAG, "updateLines: ${lines.size} lines")
adapter.setLines(lines)
}
private fun handleEvent(event: PageViewModel.Event) {
Log.d(TAG, "handleEvent: $event")
if (!event.handled) {
when (event) {
is PageViewModel.InputEvent -> {
val editText = EditText(this).apply { inputType = InputType.TYPE_CLASS_TEXT }
val inputView = FrameLayout(this).apply {
addView(FrameLayout(this@MainActivity).apply {
addView(editText)
val params = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
params.setMargins(resources.getDimensionPixelSize(R.dimen.text_margin))
layoutParams = params
})
}
AlertDialog.Builder(this)
.setMessage(if (event.prompt.isNotEmpty()) event.prompt else "Input required")
.setView(inputView)
.setPositiveButton(android.R.string.ok) { _, _ ->
val newUri = event.uri.buildUpon().query(editText.text.toString()).build()
openUrl(newUri.toString(), base = currentUrl)
}
.setOnDismissListener { updateState(PageViewModel.State.IDLE) }
.create()
.show()
}
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\nServer details: ${event.serverDetails}"
if (!isConnectedToNetwork(this))
message += "\n\nInternet may be inaccessible…"
alert(message, title = event.short)
updateState(PageViewModel.State.IDLE)
}
}
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 find an app to open \"${uri.scheme}\" URLs.")
}
}
companion object {
const val TAG = "MainActivity"
}

View file

@ -0,0 +1,210 @@
package dev.lowrespalmtree.comet
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.InputType
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.FrameLayout
import androidx.appcompat.app.AlertDialog
import androidx.core.view.setMargins
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import dev.lowrespalmtree.comet.databinding.FragmentPageViewBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ExperimentalCoroutinesApi
class PageViewFragment : Fragment(), ContentAdapter.ContentAdapterListener {
private lateinit var binding: FragmentPageViewBinding
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>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentPageViewBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
pageViewModel = ViewModelProvider(this)[PageViewModel::class.java]
adapter = ContentAdapter(this)
binding.contentRecycler.layoutManager = LinearLayoutManager(requireContext())
binding.contentRecycler.adapter = adapter
binding.addressBar.setOnEditorActionListener { editText, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
openUrl(editText.text.toString())
activity?.run {
val imm = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(editText.windowToken, 0)
}
editText.clearFocus()
true
} else {
false
}
}
binding.contentSwipeLayout.setOnRefreshListener { openUrl(currentUrl) }
pageViewModel.state.observe(viewLifecycleOwner, { updateState(it) })
pageViewModel.lines.observe(viewLifecycleOwner, { updateLines(it) })
pageViewModel.event.observe(viewLifecycleOwner, { handleEvent(it) })
}
override fun onLinkClick(url: String) {
openUrl(url, base = if (currentUrl.isNotEmpty()) currentUrl else null)
}
fun onBackPressed(): Boolean {
visitedUrls.removeLastOrNull() // Always remove current page first.
val previousUrl = visitedUrls.removeLastOrNull()
if (previousUrl != null) {
openUrl(previousUrl)
return true
}
return false
}
/**
* Open an URL.
*
* This function can be called after the user entered an URL in the app bar, clicked on a link,
* whatever. To make the user's life a bit easier, this function also makes a few guesses:
* - If the URL is not absolute, make it so from a base URL (e.g. the current URL) or assume
* the user only typed a hostname without scheme and use a utility function to make it
* absolute.
* - If it's an absolute Gemini URL with an empty path, use "/" instead as per the spec.
*/
private fun openUrl(url: String, base: String? = null, redirections: Int = 0) {
if (redirections >= 5) {
alert("Too many redirections.")
return
}
var uri = Uri.parse(url)
if (!uri.isAbsolute) {
uri = if (!base.isNullOrEmpty()) joinUrls(base, url) else toGeminiUri(uri)
} else if (uri.scheme == "gemini" && uri.path.isNullOrEmpty()) {
uri = uri.buildUpon().path("/").build()
}
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.appBar.setExpanded(true, true)
binding.contentSwipeLayout.isRefreshing = false
}
}
}
private fun updateLines(lines: List<Line>) {
Log.d(TAG, "updateLines: ${lines.size} lines")
adapter.setLines(lines)
}
private fun handleEvent(event: PageViewModel.Event) {
Log.d(TAG, "handleEvent: $event")
if (!event.handled) {
when (event) {
is PageViewModel.InputEvent -> {
val editText = EditText(requireContext())
editText.inputType = InputType.TYPE_CLASS_TEXT
val inputView = FrameLayout(requireContext()).apply {
addView(FrameLayout(requireContext()).apply {
addView(editText)
val params = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
params.setMargins(resources.getDimensionPixelSize(R.dimen.text_margin))
layoutParams = params
})
}
AlertDialog.Builder(requireContext())
.setMessage(if (event.prompt.isNotEmpty()) event.prompt else "Input required")
.setView(inputView)
.setPositiveButton(android.R.string.ok) { _, _ ->
val newUri =
event.uri.buildUpon().query(editText.text.toString()).build()
openUrl(newUri.toString(), base = currentUrl)
}
.setOnDismissListener { updateState(PageViewModel.State.IDLE) }
.create()
.show()
}
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\nServer details: ${event.serverDetails}"
if (!isConnectedToNetwork(requireContext()))
message += "\n\nInternet may be inaccessible…"
alert(message, title = event.short)
updateState(PageViewModel.State.IDLE)
}
}
event.handled = true
}
}
private fun alert(message: String, title: String? = null) {
AlertDialog.Builder(requireContext())
.setTitle(title ?: getString(R.string.error_alert_title))
.setMessage(message)
.create()
.show()
}
private fun openUnknownScheme(uri: Uri) {
try {
startActivity(Intent(Intent.ACTION_VIEW, uri))
} catch (e: ActivityNotFoundException) {
alert("Can't find an app to open \"${uri.scheme}\" URLs.")
}
}
companion object {
private const val TAG = "PageViewFragment"
}
}

View file

@ -1,71 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
android:layout_width="match_parent"
android:layout_height="56dp">
<Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/main_content"
android:layout_height="match_parent"
app:contentScrim="?attr/colorPrimary"
app:layout_scrollFlags="scroll|enterAlways"
app:toolbarId="@+id/toolbar">
<EditText
android:id="@+id/address_bar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="4dp"
android:hint="@string/url"
android:imeOptions="actionDone|actionGo"
android:importantForAutofill="no"
android:inputType="textUri"
android:text=""
tools:ignore="TextContrastCheck" />
</Toolbar>
</com.google.android.material.appbar.AppBarLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/content_swipe_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<RelativeLayout
android:id="@+id/content_inner_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
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>
android:layout_width="match_parent" />

View file

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
android:layout_width="match_parent"
android:layout_height="56dp">
<Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:contentScrim="?attr/colorPrimary"
app:layout_scrollFlags="scroll|enterAlways"
app:toolbarId="@+id/toolbar">
<EditText
android:id="@+id/address_bar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="4dp"
android:hint="@string/url"
android:imeOptions="actionDone|actionGo"
android:importantForAutofill="no"
android:inputType="textUri"
android:text=""
tools:ignore="TextContrastCheck" />
</Toolbar>
</com.google.android.material.appbar.AppBarLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/content_swipe_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<RelativeLayout
android:id="@+id/content_inner_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
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>