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.

232 lines
9.1 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.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.TextView
import androidx.activity.addCallback
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import dev.lowrespalmtree.comet.databinding.FragmentPageViewBinding
import dev.lowrespalmtree.comet.utils.isConnectedToNetwork
import dev.lowrespalmtree.comet.utils.resolveLinkUri
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ExperimentalCoroutinesApi
class PageFragment : Fragment(), PageAdapter.Listener {
private val vm: PageViewModel by viewModels()
private val args: PageFragmentArgs by navArgs()
private lateinit var binding: FragmentPageViewBinding
private lateinit var adapter: PageAdapter
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
Log.d(TAG, "onCreateView")
binding = FragmentPageViewBinding.inflate(layoutInflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
Log.d(TAG, "onViewCreated (args: ${args})")
binding.contentRecycler.layoutManager = LinearLayoutManager(requireContext())
adapter = PageAdapter(this)
binding.contentRecycler.adapter = adapter
binding.addressBar.setOnEditorActionListener { v, id, _ -> onAddressBarAction(v, id) }
binding.contentSwipeLayout.setOnRefreshListener { openUrl(vm.currentUrl) }
vm.state.observe(viewLifecycleOwner) { updateState(it) }
vm.lines.observe(viewLifecycleOwner) { updateLines(it.second, it.first) }
vm.event.observe(viewLifecycleOwner) { handleEvent(it) }
(activity as MainActivity?)?.let {
it.onBackPressedDispatcher.addCallback(viewLifecycleOwner) { onBackPressed() }
it.supportActionBar?.title = vm.currentTitle
}
val url = args.url
if (vm.currentUrl.isEmpty() && url.isNotEmpty()) {
Log.d(TAG, "onViewCreated: open \"$url\"")
openUrl(url)
} else if (vm.visitedUrls.isEmpty()) {
Log.d(TAG, "onViewCreated: no current URL, open home if configured")
Preferences.getHomeUrl(requireContext())?.let { if (it.isNotBlank()) openUrl(it) }
}
}
override fun onLinkClick(url: String) {
openUrl(url, base = vm.currentUrl.ifEmpty { null })
}
private fun onBackPressed() {
Log.d(TAG, "onBackPressed")
if (vm.visitedUrls.size >= 2) {
vm.visitedUrls.removeLastOrNull() // Always remove current page first.
vm.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
}
val uri = resolveLinkUri(url, base)
when (uri.scheme) {
"gemini" -> vm.sendGeminiRequest(uri, requireContext())
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
binding.addressBar.setText(vm.currentUrl)
binding.addressBar.setTextColor(resources.getColor(R.color.url_bar, null))
}
PageViewModel.State.CONNECTING -> {
binding.appBarLayout.setExpanded(true, true)
binding.contentProgressBar.show()
binding.addressBar.setText(vm.loadingUrl?.toString() ?: "")
binding.addressBar.setTextColor(resources.getColor(R.color.url_bar_loading, null))
}
PageViewModel.State.RECEIVING -> {
binding.contentRecycler.smoothScrollToPosition(0)
binding.contentSwipeLayout.isRefreshing = false
}
}
}
private fun updateLines(lines: List<Line>, url: String) {
Log.d(TAG, "updateLines: ${lines.size} lines from $url")
adapter.setLines(lines)
}
private fun handleEvent(event: PageViewModel.Event) {
Log.d(TAG, "handleEvent: $event")
if (event.handled)
return
when (event) {
is PageViewModel.InputEvent -> {
InputDialog(requireContext(), event.prompt.ifEmpty { "Input required" })
.show(
onOk = { text ->
val newUri = event.uri.buildUpon().query(text).build()
openUrl(newUri.toString(), base = vm.currentUrl)
},
onDismiss = {}
)
}
is PageViewModel.SuccessEvent -> {
vm.currentUrl = event.uri
vm.currentTitle = event.mainTitle ?: ""
if (vm.visitedUrls.isEmpty() || vm.visitedUrls.last() != event.uri)
vm.visitedUrls.add(event.uri)
binding.addressBar.setText(event.uri)
(activity as MainActivity?)?.supportActionBar?.title = vm.currentTitle
}
is PageViewModel.BinaryEvent -> {
// TODO this should present the user with options on what to do according to the
// MIME type: show inline, save in the media store, save as generic download, etc.
vm.downloadResponse(
event.response.data,
event.uri,
event.mimeType,
requireContext().contentResolver
)
}
is PageViewModel.DownloadCompletedEvent -> {
val message = when (event.mimeType.main) {
"image" -> R.string.image_download_completed
else -> R.string.download_completed
}
Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT)
.setAction(R.string.open) {
startActivity(Intent(Intent.ACTION_VIEW).apply {
setDataAndType(event.uri, event.mimeType.short)
})
}
.show()
}
is PageViewModel.RedirectEvent -> {
openUrl(
event.uri,
base = vm.currentUrl.ifEmpty { event.sourceUri },
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)
}
}
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 = "PageFragment"
}
}