diff --git a/app/src/main/java/dev/lowrespalmtree/comet/ContentAdapter.kt b/app/src/main/java/dev/lowrespalmtree/comet/ContentAdapter.kt index 6b82d4c..3f630b6 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/ContentAdapter.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/ContentAdapter.kt @@ -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() { private var lines = listOf() @@ -27,7 +27,7 @@ class ContentAdapter(private val listener: ContentAdapterListen) : private var blocks = mutableListOf() private var lastBlockCount = 0 - interface ContentAdapterListen { + interface ContentAdapterListener { fun onLinkClick(url: String) } diff --git a/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt b/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt index 9cfdce9..f9e8bb7 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt @@ -1,198 +1,29 @@ 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() - +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 - 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) { - 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.") - } + // TODO pass to PageViewFragment + super.onBackPressed() } companion object { diff --git a/app/src/main/java/dev/lowrespalmtree/comet/PageViewFragment.kt b/app/src/main/java/dev/lowrespalmtree/comet/PageViewFragment.kt new file mode 100644 index 0000000..de080e1 --- /dev/null +++ b/app/src/main/java/dev/lowrespalmtree/comet/PageViewFragment.kt @@ -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() + + 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) { + 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" + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index d36c1a3..2bfd55e 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,71 +1,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/main_content" + android:layout_height="match_parent" + android:layout_width="match_parent" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_page_view.xml b/app/src/main/res/layout/fragment_page_view.xml new file mode 100644 index 0000000..d36c1a3 --- /dev/null +++ b/app/src/main/res/layout/fragment_page_view.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file