You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

228 lines
9.0 KiB

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 android.widget.TextView
import androidx.activity.addCallback
import androidx.appcompat.app.AlertDialog
import androidx.core.view.setMargins
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import dev.lowrespalmtree.comet.ContentAdapter.ContentAdapterListener
import dev.lowrespalmtree.comet.databinding.FragmentPageViewBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ExperimentalCoroutinesApi
class PageViewFragment : Fragment(), 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 { v, id, _ -> onAddressBarAction(v, id) }
binding.contentSwipeLayout.setOnRefreshListener { openUrl(currentUrl) }
pageViewModel.state.observe(viewLifecycleOwner, { updateState(it) })
pageViewModel.lines.observe(viewLifecycleOwner, { updateLines(it) })
pageViewModel.event.observe(viewLifecycleOwner, { handleEvent(it) })
activity?.onBackPressedDispatcher?.addCallback(viewLifecycleOwner) { onBackPressed() }
val url = arguments?.getString("url")
if (!url.isNullOrEmpty()) {
openUrl(url)
} else if (visitedUrls.isEmpty()) {
Preferences.getHomeUrl(requireContext())?.let { openUrl(it) }
}
}
override fun onLinkClick(url: String) {
openUrl(url, base = if (currentUrl.isNotEmpty()) currentUrl else null)
}
private fun onBackPressed() {
if (visitedUrls.size >= 2) {
visitedUrls.removeLastOrNull() // Always remove current page first.
visitedUrls.removeLastOrNull()?.also { openUrl(it) }
}
}
private fun onAddressBarAction(addressBar: TextView, actionId: Int): Boolean {
if (actionId == EditorInfo.IME_ACTION_DONE) {
openUrl(addressBar.text.toString())
activity?.run {
val imm = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(addressBar.windowToken, 0)
}
addressBar.clearFocus()
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" -> {
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
val connectionTimeout =
prefs.getInt("connection_timeout", Request.DEFAULT_CONNECTION_TIMEOUT_SEC)
val readTimeout =
prefs.getInt("read_timeout", Request.DEFAULT_READ_TIMEOUT_SEC)
pageViewModel.sendGeminiRequest(uri, connectionTimeout, readTimeout)
}
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.appBarLayout.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"
}
}