diff --git a/app/src/main/java/dev/lowrespalmtree/comet/ContentRecycler.kt b/app/src/main/java/dev/lowrespalmtree/comet/ContentRecycler.kt new file mode 100644 index 0000000..e500c26 --- /dev/null +++ b/app/src/main/java/dev/lowrespalmtree/comet/ContentRecycler.kt @@ -0,0 +1,109 @@ +package dev.lowrespalmtree.comet + +import android.text.SpannableString +import android.text.style.UnderlineSpan +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import dev.lowrespalmtree.comet.databinding.* + +class ContentAdapter(private var content: List, private val listener: ContentAdapterListen) : + RecyclerView.Adapter() { + + interface ContentAdapterListen { + fun onLinkClick(url: String) + } + + override fun getItemViewType(position: Int): Int = + when (content[position]) { + is EmptyLine -> TYPE_EMPTY + is ParagraphLine -> TYPE_PARAGRAPH + is LinkLine -> TYPE_LINK + is PreFenceLine -> TYPE_PRE_FENCE + is PreTextLine -> TYPE_PRE_TEXT + is BlockquoteLine -> TYPE_BLOCKQUOTE + is ListItemLine -> TYPE_LIST_ITEM + is TitleLine -> when ((content[position] as TitleLine).level) { + 1 -> TYPE_TITLE_1 + 2 -> TYPE_TITLE_2 + 3 -> TYPE_TITLE_3 + else -> error("invalid title level") + } + else -> error("unknown line type") + } + + 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)) + TYPE_PARAGRAPH -> ContentViewHolder.Paragraph(GemtextParagraphBinding.inflate(it)) + TYPE_TITLE_1 -> ContentViewHolder.Title1(GemtextTitle1Binding.inflate(it)) + TYPE_TITLE_2 -> ContentViewHolder.Title2(GemtextTitle2Binding.inflate(it)) + TYPE_TITLE_3 -> ContentViewHolder.Title3(GemtextTitle3Binding.inflate(it)) + TYPE_LINK -> ContentViewHolder.Link(GemtextLinkBinding.inflate(it)) + TYPE_PRE_FENCE -> ContentViewHolder.PreFence(GemtextEmptyBinding.inflate(it)) + TYPE_PRE_TEXT -> ContentViewHolder.PreText(GemtextPreformattedBinding.inflate(it)) + TYPE_BLOCKQUOTE -> ContentViewHolder.Blockquote(GemtextBlockquoteBinding.inflate(it)) + TYPE_LIST_ITEM -> ContentViewHolder.ListItem(GemtextListItemBinding.inflate(it)) + else -> error("invalid view type") + } + } + } + + 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.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 + is ContentViewHolder.Link -> { + val text = if ((line as LinkLine).label.isNotEmpty()) line.label else line.url + val underlined = SpannableString(text) + underlined.setSpan(UnderlineSpan(), 0, underlined.length, 0) + holder.binding.textView.text = underlined + holder.binding.root.setOnClickListener { listener.onLinkClick(line.url) } + } + else -> {} + } + } + + override fun getItemCount(): Int = content.size + + fun setContent(content: List) { + this.content = content + notifyDataSetChanged() + } + + sealed class ContentViewHolder(view: View) : RecyclerView.ViewHolder(view) { + class Empty(val binding: GemtextEmptyBinding) : ContentViewHolder(binding.root) + class Paragraph(val binding: GemtextParagraphBinding) : ContentViewHolder(binding.root) + class Title1(val binding: GemtextTitle1Binding) : ContentViewHolder(binding.root) + class Title2(val binding: GemtextTitle2Binding) : ContentViewHolder(binding.root) + class Title3(val binding: GemtextTitle3Binding) : ContentViewHolder(binding.root) + class Link(val binding: GemtextLinkBinding) : ContentViewHolder(binding.root) + class PreFence(val binding: GemtextEmptyBinding) : ContentViewHolder(binding.root) + class PreText(val binding: GemtextPreformattedBinding) : ContentViewHolder(binding.root) + class Blockquote(val binding: GemtextBlockquoteBinding) : ContentViewHolder(binding.root) + class ListItem(val binding: GemtextListItemBinding) : ContentViewHolder(binding.root) + } + + companion object { + const val TAG = "ContentRecycler" + const val TYPE_EMPTY = 0 + const val TYPE_PARAGRAPH = 1 + const val TYPE_TITLE_1 = 2 + const val TYPE_TITLE_2 = 3 + const val TYPE_TITLE_3 = 4 + const val TYPE_LINK = 5 + const val TYPE_PRE_FENCE = 6 + const val TYPE_PRE_TEXT = 7 + const val TYPE_BLOCKQUOTE = 8 + const val TYPE_LIST_ITEM = 9 + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/lowrespalmtree/comet/Gemtext.kt b/app/src/main/java/dev/lowrespalmtree/comet/Gemtext.kt index 5e8a7cb..85f987e 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/Gemtext.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/Gemtext.kt @@ -7,16 +7,16 @@ import java.nio.ByteBuffer import java.nio.CharBuffer import java.nio.charset.Charset -open class Line +interface Line -class EmptyLine : Line() -class ParagraphLine(val text: String) : Line() -class TitleLine(val level: Int, val text: String) : Line() -class LinkLine(val url: String, val label: String) : Line() -class PreFenceLine(val caption: String) : Line() -class PreTextLine(val text: String) : Line() -class BlockquoteLine(val text: String) : Line() -class ListItemLine(val text: String) : Line() +class EmptyLine : Line +class ParagraphLine(val text: String) : Line +class TitleLine(val level: Int, val text: String) : Line +class LinkLine(val url: String, val label: String) : Line +class PreFenceLine(val caption: String) : Line +class PreTextLine(val text: String) : Line +class BlockquoteLine(val text: String) : Line +class ListItemLine(val text: String) : Line fun parseData( inChannel: Channel, @@ -59,6 +59,4 @@ private fun parseLine(line: CharBuffer, isPreformatted: Boolean): Line = else -> if (isPreformatted) PreTextLine(line.toString()) else ParagraphLine(line.toString()) } -private fun getCharsFrom(line: CharBuffer, index: Int) = line.substring(index).removePrefix(" ") - -private const val TAG = "Gemtext" \ No newline at end of file +private fun getCharsFrom(line: CharBuffer, index: Int) = line.substring(index).removePrefix(" ") \ No newline at end of file diff --git a/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt b/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt index 950dee2..09e0747 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt @@ -2,6 +2,8 @@ package dev.lowrespalmtree.comet import android.annotation.SuppressLint import android.app.Activity +import android.content.Intent +import android.content.Intent.ACTION_VIEW import android.net.Uri import android.os.Bundle import android.util.Log @@ -13,15 +15,17 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import androidx.recyclerview.widget.LinearLayoutManager import dev.lowrespalmtree.comet.databinding.ActivityMainBinding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.nio.ByteBuffer +import java.net.UnknownHostException import java.nio.charset.Charset -class MainActivity : AppCompatActivity() { +class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen { private lateinit var binding: ActivityMainBinding private lateinit var pageViewModel: PageViewModel + private lateinit var adapter: ContentAdapter @SuppressLint("SetTextI18n") override fun onCreate(savedInstanceState: Bundle?) { @@ -30,10 +34,13 @@ class MainActivity : AppCompatActivity() { setContentView(binding.root) pageViewModel = ViewModelProvider(this)[PageViewModel::class.java] + adapter = ContentAdapter(listOf(), this) + binding.contentRecycler.layoutManager = LinearLayoutManager(this) + binding.contentRecycler.adapter = adapter binding.addressBar.setOnEditorActionListener { view, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_DONE) { - pageViewModel.sendRequest(view.text.toString()) + openUrl(view.text.toString()) val imm = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(view.windowToken, 0) view.clearFocus() @@ -43,37 +50,58 @@ class MainActivity : AppCompatActivity() { } } - pageViewModel.sourceLiveData.observe(this, { - binding.sourceBlock.text = it - }) - pageViewModel.alertLiveData.observe(this, { - AlertDialog.Builder(this) - .setTitle(R.string.alert_title) - .setMessage(it) - .create() - .show() - }) + pageViewModel.linesLiveData.observe(this, { adapter.setContent(it) }) + pageViewModel.alertLiveData.observe(this, { alert(it) }) + } + + override fun onLinkClick(url: String) { + val base = binding.addressBar.text.toString() + openUrl(url, base = if (base.isNotEmpty()) base else null) + } + + private fun openUrl(url: String, base: String? = null) { + var uri = Uri.parse(url) + + when (uri.scheme) { + "gemini" -> pageViewModel.sendGeminiRequest(uri) + else -> startActivity(Intent(ACTION_VIEW, uri)) + } + } + + private fun alert(message: String) { + AlertDialog.Builder(this) + .setTitle(R.string.alert_title) + .setMessage(message) + .create() + .show() } class PageViewModel : ViewModel() { - var source = "" - val sourceLiveData: MutableLiveData by lazy { MutableLiveData() } + private var lines = ArrayList() + val linesLiveData: MutableLiveData> by lazy { MutableLiveData>() } val alertLiveData: MutableLiveData by lazy { MutableLiveData() } - fun sendRequest(url: String) { - Log.d(TAG, "sendRequest: $url") - source = "" + /** + * Perform a request against this URI. + * + * The URI must be valid, absolute and with a gemini scheme. + */ + fun sendGeminiRequest(uri: Uri) { + Log.d(TAG, "sendRequest: $uri") viewModelScope.launch(Dispatchers.IO) { - val uri = Uri.parse(url) - if (uri.scheme != "gemini") { - alertLiveData.postValue("Can't process scheme \"${uri.scheme}\".") + 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}\".") + return@launch + } catch (e: Exception) { + Log.e(TAG, "sendGeminiRequest coroutine: ${e.stackTraceToString()}") + alertLiveData.postValue("Oops! Whatever we tried to do failed!") return@launch } - - val request = Request(uri) - val socket = request.connect() - val channel = request.proceed(socket, this) - val response = Response.from(channel, viewModelScope) if (response == null) { alertLiveData.postValue("Can't parse server response.") return@launch @@ -85,22 +113,15 @@ class MainActivity : AppCompatActivity() { else -> alertLiveData.postValue("Can't handle code ${response.code}.") } } + } private suspend fun handleRequestSuccess(response: Response) { + lines.clear() val charset = Charset.defaultCharset() for (line in parseData(response.data, charset, viewModelScope)) { - when (line) { - is EmptyLine -> { source += "\n" } - is ParagraphLine -> { source += line.text + "\n" } - is TitleLine -> { source += "TTL-${line.level} ${line.text}\n" } - is LinkLine -> { source += "LNK ${line.url} + ${line.label}\n" } - is PreFenceLine -> { source += "PRE ${line.caption}\n" } - is PreTextLine -> { source += line.text + "\n" } - is BlockquoteLine -> { source += "QUO ${line.text}\n" } - is ListItemLine -> { source += "LST ${line.text}\n" } - } - sourceLiveData.postValue(source) + lines.add(line) + linesLiveData.postValue(lines) } } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index b0194b4..219a496 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -38,26 +38,14 @@ - - - - - + android:orientation="vertical" /> \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_blockquote.xml b/app/src/main/res/layout/gemtext_blockquote.xml new file mode 100644 index 0000000..2d46bbf --- /dev/null +++ b/app/src/main/res/layout/gemtext_blockquote.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_empty.xml b/app/src/main/res/layout/gemtext_empty.xml new file mode 100644 index 0000000..36ef82a --- /dev/null +++ b/app/src/main/res/layout/gemtext_empty.xml @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_link.xml b/app/src/main/res/layout/gemtext_link.xml new file mode 100644 index 0000000..bc27299 --- /dev/null +++ b/app/src/main/res/layout/gemtext_link.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_list_item.xml b/app/src/main/res/layout/gemtext_list_item.xml new file mode 100644 index 0000000..2d46bbf --- /dev/null +++ b/app/src/main/res/layout/gemtext_list_item.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_paragraph.xml b/app/src/main/res/layout/gemtext_paragraph.xml new file mode 100644 index 0000000..3868c58 --- /dev/null +++ b/app/src/main/res/layout/gemtext_paragraph.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_preformatted.xml b/app/src/main/res/layout/gemtext_preformatted.xml new file mode 100644 index 0000000..2d46bbf --- /dev/null +++ b/app/src/main/res/layout/gemtext_preformatted.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_title1.xml b/app/src/main/res/layout/gemtext_title1.xml new file mode 100644 index 0000000..fc0095f --- /dev/null +++ b/app/src/main/res/layout/gemtext_title1.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_title2.xml b/app/src/main/res/layout/gemtext_title2.xml new file mode 100644 index 0000000..b02f206 --- /dev/null +++ b/app/src/main/res/layout/gemtext_title2.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_title3.xml b/app/src/main/res/layout/gemtext_title3.xml new file mode 100644 index 0000000..0887625 --- /dev/null +++ b/app/src/main/res/layout/gemtext_title3.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file