Compare commits
4 commits
a822287de2
...
50793d1440
Author | SHA1 | Date | |
---|---|---|---|
dece | 50793d1440 | ||
dece | 3aea6f42c9 | ||
dece | c4d0e66322 | ||
dece | 8c1bebf315 |
|
@ -13,7 +13,7 @@ interface Line
|
||||||
class EmptyLine : Line
|
class EmptyLine : Line
|
||||||
class ParagraphLine(val text: String) : Line
|
class ParagraphLine(val text: String) : Line
|
||||||
class TitleLine(val level: Int, val text: String) : Line
|
class TitleLine(val level: Int, val text: String) : Line
|
||||||
class LinkLine(val url: String, val label: String) : Line
|
class LinkLine(val url: String, val label: String, var visited: Boolean = false) : Line
|
||||||
class PreFenceLine(val caption: String) : Line
|
class PreFenceLine(val caption: String) : Line
|
||||||
class PreTextLine(val text: String) : Line
|
class PreTextLine(val text: String) : Line
|
||||||
class BlockquoteLine(val text: String) : Line
|
class BlockquoteLine(val text: String) : Line
|
||||||
|
@ -21,7 +21,7 @@ class ListItemLine(val text: String) : Line
|
||||||
|
|
||||||
private const val TAG = "Gemtext"
|
private const val TAG = "Gemtext"
|
||||||
|
|
||||||
/** Pipe incoming gemtext data into parsed Lines. */
|
/** Pipe incoming Gemtext data into parsed Lines. */
|
||||||
fun parseData(
|
fun parseData(
|
||||||
inChannel: Channel<ByteArray>,
|
inChannel: Channel<ByteArray>,
|
||||||
charset: Charset,
|
charset: Charset,
|
||||||
|
|
|
@ -38,6 +38,10 @@ object History {
|
||||||
dao.update(entry.also { it.title = title; it.lastVisit = now })
|
dao.update(entry.also { it.title = title; it.lastVisit = now })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun contains(uri: String): Boolean = get(uri) != null
|
||||||
|
|
||||||
|
suspend fun get(uri: String): HistoryEntry? = Database.INSTANCE.historyEntryDao().get(uri)
|
||||||
|
|
||||||
suspend fun getAll(): List<HistoryEntry> = Database.INSTANCE.historyEntryDao().getAll()
|
suspend fun getAll(): List<HistoryEntry> = Database.INSTANCE.historyEntryDao().getAll()
|
||||||
|
|
||||||
suspend fun getLast(): HistoryEntry? = Database.INSTANCE.historyEntryDao().getLast()
|
suspend fun getLast(): HistoryEntry? = Database.INSTANCE.historyEntryDao().getLast()
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
package dev.lowrespalmtree.comet
|
package dev.lowrespalmtree.comet
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.text.format.DateFormat
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import dev.lowrespalmtree.comet.History.HistoryEntry
|
import dev.lowrespalmtree.comet.History.HistoryEntry
|
||||||
import dev.lowrespalmtree.comet.databinding.FragmentHistoryItemBinding
|
import dev.lowrespalmtree.comet.databinding.FragmentHistoryItemBinding
|
||||||
import dev.lowrespalmtree.comet.utils.getFancySelectBgRes
|
import dev.lowrespalmtree.comet.utils.getFancySelectBgRes
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
class HistoryAdapter(private val listener: Listener) :
|
class HistoryAdapter(private val listener: Listener) :
|
||||||
RecyclerView.Adapter<HistoryAdapter.ViewHolder>() {
|
RecyclerView.Adapter<HistoryAdapter.ViewHolder>() {
|
||||||
|
@ -27,12 +28,17 @@ class HistoryAdapter(private val listener: Listener) :
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
val item = items[position]
|
val entry = items[position]
|
||||||
holder.binding.uriText.text = item.uri
|
// URI is always show.
|
||||||
holder.binding.titleText.visibility =
|
holder.binding.uriText.text = entry.uri
|
||||||
if (item.title.isNullOrBlank()) View.GONE else View.VISIBLE
|
// Main title is shown if one was found when record the entry.
|
||||||
holder.binding.titleText.text = item.title ?: ""
|
holder.binding.titleText.text = entry.title ?: ""
|
||||||
holder.binding.container.setOnClickListener { listener.onItemClick(item.uri) }
|
// Last visited date is properly formatted.
|
||||||
|
val lastVisit = Date(entry.lastVisit)
|
||||||
|
val dateFormatter = DateFormat.getMediumDateFormat(holder.binding.root.context)
|
||||||
|
holder.binding.lastVisitText.text = dateFormatter.format(lastVisit)
|
||||||
|
// Bind the click action.
|
||||||
|
holder.binding.container.setOnClickListener { listener.onItemClick(entry.uri) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount(): Int = items.size
|
override fun getItemCount(): Int = items.size
|
||||||
|
|
|
@ -4,12 +4,16 @@ import android.security.keystore.KeyGenParameterSpec
|
||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
import java.lang.IllegalArgumentException
|
|
||||||
import java.security.KeyPairGenerator
|
import java.security.KeyPairGenerator
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import javax.security.auth.x500.X500Principal
|
import javax.security.auth.x500.X500Principal
|
||||||
|
|
||||||
object Identities {
|
object Identities {
|
||||||
|
const val PROVIDER = "AndroidKeyStore"
|
||||||
|
private const val TAG = "Identities"
|
||||||
|
|
||||||
|
val keyStore by lazy { KeyStore.getInstance(PROVIDER).apply { load(null) } }
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
data class Identity(
|
data class Identity(
|
||||||
/** ID. */
|
/** ID. */
|
||||||
|
@ -49,6 +53,10 @@ object Identities {
|
||||||
suspend fun getAll(): List<Identity> =
|
suspend fun getAll(): List<Identity> =
|
||||||
Database.INSTANCE.identityDao().getAll()
|
Database.INSTANCE.identityDao().getAll()
|
||||||
|
|
||||||
|
suspend fun getForUrl(url: String): Identity? =
|
||||||
|
Database.INSTANCE.identityDao().getAll()
|
||||||
|
.find { it.urls.any { usedUrl -> url.startsWith(usedUrl) } }
|
||||||
|
|
||||||
suspend fun update(vararg identities: Identity) =
|
suspend fun update(vararg identities: Identity) =
|
||||||
Database.INSTANCE.identityDao().update(*identities)
|
Database.INSTANCE.identityDao().update(*identities)
|
||||||
|
|
||||||
|
@ -62,7 +70,7 @@ object Identities {
|
||||||
|
|
||||||
fun generateClientCert(alias: String, commonName: String) {
|
fun generateClientCert(alias: String, commonName: String) {
|
||||||
val algo = KeyProperties.KEY_ALGORITHM_RSA
|
val algo = KeyProperties.KEY_ALGORITHM_RSA
|
||||||
val kpg = KeyPairGenerator.getInstance(algo, "AndroidKeyStore")
|
val kpg = KeyPairGenerator.getInstance(algo, PROVIDER)
|
||||||
val purposes = KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
|
val purposes = KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
|
||||||
val spec = KeyGenParameterSpec.Builder(alias, purposes)
|
val spec = KeyGenParameterSpec.Builder(alias, purposes)
|
||||||
.apply {
|
.apply {
|
||||||
|
@ -74,7 +82,9 @@ object Identities {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.setDigests(KeyProperties.DIGEST_SHA256)
|
.setDigests(KeyProperties.DIGEST_NONE, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
|
||||||
.build()
|
.build()
|
||||||
kpg.initialize(spec)
|
kpg.initialize(spec)
|
||||||
kpg.generateKeyPair()
|
kpg.generateKeyPair()
|
||||||
|
@ -82,7 +92,6 @@ object Identities {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteClientCert(alias: String) {
|
private fun deleteClientCert(alias: String) {
|
||||||
val keyStore = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
|
|
||||||
if (keyStore.containsAlias(alias)) {
|
if (keyStore.containsAlias(alias)) {
|
||||||
keyStore.deleteEntry(alias)
|
keyStore.deleteEntry(alias)
|
||||||
Log.i(TAG, "deleteClientCert: deleted entry with alias \"$alias\"")
|
Log.i(TAG, "deleteClientCert: deleted entry with alias \"$alias\"")
|
||||||
|
@ -90,6 +99,4 @@ object Identities {
|
||||||
Log.i(TAG, "deleteClientCert: no such alias \"$alias\"")
|
Log.i(TAG, "deleteClientCert: no such alias \"$alias\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val TAG = "Identities"
|
|
||||||
}
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
package dev.lowrespalmtree.comet
|
package dev.lowrespalmtree.comet
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.os.bundleOf
|
import androidx.core.os.bundleOf
|
||||||
|
@ -15,6 +16,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
private var nhf: NavHostFragment? = null
|
private var nhf: NavHostFragment? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
Log.i(TAG, "onCreate")
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
@ -32,4 +34,8 @@ class MainActivity : AppCompatActivity() {
|
||||||
nhf?.navController?.navigate(R.id.action_global_pageFragment, bundle)
|
nhf?.navController?.navigate(R.id.action_global_pageFragment, bundle)
|
||||||
binding.drawerLayout.closeDrawers()
|
binding.drawerLayout.closeDrawers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MainActivity"
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
package dev.lowrespalmtree.comet
|
package dev.lowrespalmtree.comet
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
@ -33,7 +34,7 @@ class PageAdapter(private val listener: Listener) :
|
||||||
object Empty : ContentBlock()
|
object Empty : ContentBlock()
|
||||||
class Paragraph(val text: String) : ContentBlock()
|
class Paragraph(val text: String) : ContentBlock()
|
||||||
class Title(val text: String, val level: Int) : ContentBlock()
|
class Title(val text: String, val level: Int) : ContentBlock()
|
||||||
class Link(val url: String, val label: String) : ContentBlock()
|
class Link(val url: String, val label: String, val visited: Boolean) : ContentBlock()
|
||||||
class Pre(val caption: String, var content: String, var closed: Boolean) : ContentBlock()
|
class Pre(val caption: String, var content: String, var closed: Boolean) : ContentBlock()
|
||||||
class Blockquote(var text: String) : ContentBlock()
|
class Blockquote(var text: String) : ContentBlock()
|
||||||
class ListItem(val text: String) : ContentBlock()
|
class ListItem(val text: String) : ContentBlock()
|
||||||
|
@ -54,7 +55,7 @@ class PageAdapter(private val listener: Listener) :
|
||||||
when (val line = lines[currentLine]) {
|
when (val line = lines[currentLine]) {
|
||||||
is EmptyLine -> blocks.add(ContentBlock.Empty)
|
is EmptyLine -> blocks.add(ContentBlock.Empty)
|
||||||
is ParagraphLine -> blocks.add(ContentBlock.Paragraph(line.text))
|
is ParagraphLine -> blocks.add(ContentBlock.Paragraph(line.text))
|
||||||
is LinkLine -> blocks.add(ContentBlock.Link(line.url, line.label))
|
is LinkLine -> blocks.add(ContentBlock.Link(line.url, line.label, line.visited))
|
||||||
is ListItemLine -> blocks.add(ContentBlock.ListItem(line.text))
|
is ListItemLine -> blocks.add(ContentBlock.ListItem(line.text))
|
||||||
is TitleLine -> blocks.add(ContentBlock.Title(line.text, line.level))
|
is TitleLine -> blocks.add(ContentBlock.Title(line.text, line.level))
|
||||||
is PreFenceLine -> {
|
is PreFenceLine -> {
|
||||||
|
@ -95,8 +96,8 @@ class PageAdapter(private val listener: Listener) :
|
||||||
lastBlockCount = blocks.size
|
lastBlockCount = blocks.size
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class ContentViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
sealed class ContentViewHolder(val view: View) : RecyclerView.ViewHolder(view) {
|
||||||
class Empty(binding: GemtextEmptyBinding) : ContentViewHolder(binding.root)
|
class Empty(val binding: GemtextEmptyBinding) : ContentViewHolder(binding.root)
|
||||||
class Paragraph(val binding: GemtextParagraphBinding) : ContentViewHolder(binding.root)
|
class Paragraph(val binding: GemtextParagraphBinding) : ContentViewHolder(binding.root)
|
||||||
class Title1(val binding: GemtextTitle1Binding) : ContentViewHolder(binding.root)
|
class Title1(val binding: GemtextTitle1Binding) : ContentViewHolder(binding.root)
|
||||||
class Title2(val binding: GemtextTitle2Binding) : ContentViewHolder(binding.root)
|
class Title2(val binding: GemtextTitle2Binding) : ContentViewHolder(binding.root)
|
||||||
|
@ -153,9 +154,27 @@ class PageAdapter(private val listener: Listener) :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is ContentBlock.Link -> {
|
is ContentBlock.Link -> {
|
||||||
|
val uri = Uri.parse(block.url)
|
||||||
val label = block.label.ifBlank { block.url }
|
val label = block.label.ifBlank { block.url }
|
||||||
(holder as ContentViewHolder.Link).binding.textView.text = label
|
(holder as ContentViewHolder.Link).binding.textView.text = label
|
||||||
holder.binding.root.setOnClickListener { listener.onLinkClick(block.url) }
|
holder.binding.root.setOnClickListener { listener.onLinkClick(block.url) }
|
||||||
|
|
||||||
|
// Add the protocol on the right view if available and necessary (i.e. not Gemini).
|
||||||
|
if (uri.isAbsolute && uri.scheme != null && uri.scheme != "gemini") {
|
||||||
|
holder.binding.protocolView.text = uri.scheme
|
||||||
|
holder.binding.protocolView.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
holder.binding.protocolView.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color links differently if it has been already visited or not.
|
||||||
|
val resources = holder.binding.root.context.resources
|
||||||
|
holder.binding.textView.setTextColor(
|
||||||
|
if (block.visited)
|
||||||
|
resources.getColor(R.color.link_visited, null)
|
||||||
|
else
|
||||||
|
resources.getColor(R.color.link, null)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
is ContentBlock.Pre ->
|
is ContentBlock.Pre ->
|
||||||
(holder as ContentViewHolder.Pre).binding.textView.text = block.content
|
(holder as ContentViewHolder.Pre).binding.textView.text = block.content
|
||||||
|
|
|
@ -16,7 +16,6 @@ import androidx.activity.addCallback
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import dev.lowrespalmtree.comet.databinding.FragmentPageViewBinding
|
import dev.lowrespalmtree.comet.databinding.FragmentPageViewBinding
|
||||||
import dev.lowrespalmtree.comet.utils.isConnectedToNetwork
|
import dev.lowrespalmtree.comet.utils.isConnectedToNetwork
|
||||||
|
@ -51,7 +50,7 @@ class PageFragment : Fragment(), PageAdapter.Listener {
|
||||||
binding.contentSwipeLayout.setOnRefreshListener { openUrl(vm.currentUrl) }
|
binding.contentSwipeLayout.setOnRefreshListener { openUrl(vm.currentUrl) }
|
||||||
|
|
||||||
vm.state.observe(viewLifecycleOwner) { updateState(it) }
|
vm.state.observe(viewLifecycleOwner) { updateState(it) }
|
||||||
vm.lines.observe(viewLifecycleOwner) { updateLines(it) }
|
vm.lines.observe(viewLifecycleOwner) { updateLines(it.second, it.first) }
|
||||||
vm.event.observe(viewLifecycleOwner) { handleEvent(it) }
|
vm.event.observe(viewLifecycleOwner) { handleEvent(it) }
|
||||||
|
|
||||||
activity?.onBackPressedDispatcher?.addCallback(viewLifecycleOwner) { onBackPressed() }
|
activity?.onBackPressedDispatcher?.addCallback(viewLifecycleOwner) { onBackPressed() }
|
||||||
|
@ -116,16 +115,7 @@ class PageFragment : Fragment(), PageAdapter.Listener {
|
||||||
}
|
}
|
||||||
|
|
||||||
when (uri.scheme) {
|
when (uri.scheme) {
|
||||||
"gemini" -> {
|
"gemini" -> vm.sendGeminiRequest(uri, requireContext())
|
||||||
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
|
||||||
val protocol =
|
|
||||||
prefs.getString("tls_version", Request.DEFAULT_TLS_VERSION)!!
|
|
||||||
val connectionTimeout =
|
|
||||||
prefs.getInt("connection_timeout", Request.DEFAULT_CONNECTION_TIMEOUT_SEC)
|
|
||||||
val readTimeout =
|
|
||||||
prefs.getInt("read_timeout", Request.DEFAULT_READ_TIMEOUT_SEC)
|
|
||||||
vm.sendGeminiRequest(uri, protocol, connectionTimeout, readTimeout)
|
|
||||||
}
|
|
||||||
else -> openUnknownScheme(uri)
|
else -> openUnknownScheme(uri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -151,8 +141,8 @@ class PageFragment : Fragment(), PageAdapter.Listener {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateLines(lines: List<Line>) {
|
private fun updateLines(lines: List<Line>, url: String) {
|
||||||
Log.d(TAG, "updateLines: ${lines.size} lines")
|
Log.d(TAG, "updateLines: ${lines.size} lines from $url")
|
||||||
adapter.setLines(lines)
|
adapter.setLines(lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -163,6 +153,7 @@ class PageFragment : Fragment(), PageAdapter.Listener {
|
||||||
when (event) {
|
when (event) {
|
||||||
is PageViewModel.InputEvent -> {
|
is PageViewModel.InputEvent -> {
|
||||||
askForInput(event.prompt, event.uri)
|
askForInput(event.prompt, event.uri)
|
||||||
|
updateState(PageViewModel.State.IDLE)
|
||||||
}
|
}
|
||||||
is PageViewModel.SuccessEvent -> {
|
is PageViewModel.SuccessEvent -> {
|
||||||
vm.currentUrl = event.uri
|
vm.currentUrl = event.uri
|
||||||
|
@ -200,7 +191,7 @@ class PageFragment : Fragment(), PageAdapter.Listener {
|
||||||
val newUri = uri.buildUpon().query(text).build()
|
val newUri = uri.buildUpon().query(text).build()
|
||||||
openUrl(newUri.toString(), base = vm.currentUrl)
|
openUrl(newUri.toString(), base = vm.currentUrl)
|
||||||
},
|
},
|
||||||
onDismiss = { updateState(PageViewModel.State.IDLE) }
|
onDismiss = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
package dev.lowrespalmtree.comet
|
package dev.lowrespalmtree.comet
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import dev.lowrespalmtree.comet.utils.joinUrls
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.channels.onSuccess
|
import kotlinx.coroutines.channels.onSuccess
|
||||||
import java.net.ConnectException
|
import java.net.ConnectException
|
||||||
|
@ -17,18 +20,25 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
|
||||||
ViewModel() {
|
ViewModel() {
|
||||||
/** Currently viewed page URL. */
|
/** Currently viewed page URL. */
|
||||||
var currentUrl: String = ""
|
var currentUrl: String = ""
|
||||||
|
|
||||||
/** Latest Uri requested using `sendGeminiRequest`. */
|
/** Latest Uri requested using `sendGeminiRequest`. */
|
||||||
var loadingUrl: Uri? = null
|
var loadingUrl: Uri? = null
|
||||||
|
|
||||||
/** Observable page viewer state. */
|
/** Observable page viewer state. */
|
||||||
val state: MutableLiveData<State> by lazy { MutableLiveData<State>(State.IDLE) }
|
val state: MutableLiveData<State> by lazy { MutableLiveData<State>(State.IDLE) }
|
||||||
/** Observable page viewer lines (backed up by `linesList` but updated less often). */
|
|
||||||
val lines: MutableLiveData<List<Line>> by lazy { MutableLiveData<List<Line>>() }
|
/** Observable page viewer lines (backed up by `linesList` but updated less often). Left element is associated URL. */
|
||||||
|
val lines: MutableLiveData<Pair<String, List<Line>>> by lazy { MutableLiveData<Pair<String, List<Line>>>() }
|
||||||
|
|
||||||
/** Observable page viewer latest event. */
|
/** Observable page viewer latest event. */
|
||||||
val event: MutableLiveData<Event> by lazy { MutableLiveData<Event>() }
|
val event: MutableLiveData<Event> by lazy { MutableLiveData<Event>() }
|
||||||
|
|
||||||
/** A non-saved list of visited URLs. Not an history, just used for going back. */
|
/** A non-saved list of visited URLs. Not an history, just used for going back. */
|
||||||
val visitedUrls = mutableListOf<String>()
|
val visitedUrls = mutableListOf<String>()
|
||||||
|
|
||||||
/** Latest request job created, stored to cancel it if needed. */
|
/** Latest request job created, stored to cancel it if needed. */
|
||||||
private var requestJob: Job? = null
|
private var requestJob: Job? = null
|
||||||
|
|
||||||
/** Lines for the current page. */
|
/** Lines for the current page. */
|
||||||
private var linesList = ArrayList<Line>()
|
private var linesList = ArrayList<Line>()
|
||||||
|
|
||||||
|
@ -52,14 +62,36 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
|
||||||
* The URI must be valid, absolute and with a gemini scheme.
|
* The URI must be valid, absolute and with a gemini scheme.
|
||||||
*/
|
*/
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
fun sendGeminiRequest(uri: Uri, protocol: String, connectionTimeout: Int, readTimeout: Int, redirects: Int = 0) {
|
fun sendGeminiRequest(
|
||||||
Log.d(TAG, "sendRequest: URI \"$uri\"")
|
uri: Uri,
|
||||||
|
context: Context,
|
||||||
|
redirects: Int = 0
|
||||||
|
) {
|
||||||
|
Log.i(TAG, "sendGeminiRequest: URI \"$uri\"")
|
||||||
loadingUrl = uri
|
loadingUrl = uri
|
||||||
|
|
||||||
|
// Retrieve various request parameters from user preferences.
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
val protocol =
|
||||||
|
prefs.getString("tls_version", Request.DEFAULT_TLS_VERSION)!!
|
||||||
|
val connectionTimeout =
|
||||||
|
prefs.getInt("connection_timeout", Request.DEFAULT_CONNECTION_TIMEOUT_SEC)
|
||||||
|
val readTimeout =
|
||||||
|
prefs.getInt("read_timeout", Request.DEFAULT_READ_TIMEOUT_SEC)
|
||||||
|
|
||||||
state.postValue(State.CONNECTING)
|
state.postValue(State.CONNECTING)
|
||||||
|
|
||||||
requestJob?.apply { if (isActive) cancel() }
|
requestJob?.apply { if (isActive) cancel() }
|
||||||
requestJob = viewModelScope.launch(Dispatchers.IO) {
|
requestJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
// Look for a suitable identity to use with this URL.
|
||||||
|
val keyManager = Identities.getForUrl(uri.toString())?.let {
|
||||||
|
Log.d(TAG, "sendGeminiRequest coroutine: using identity with key ${it.key}")
|
||||||
|
Request.KeyManager.fromAlias(it.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to the server and proceed (no TOFU validation yet).
|
||||||
val response = try {
|
val response = try {
|
||||||
val request = Request(uri)
|
val request = Request(uri, keyManager = keyManager)
|
||||||
val socket = request.connect(protocol, connectionTimeout, readTimeout)
|
val socket = request.connect(protocol, connectionTimeout, readTimeout)
|
||||||
val channel = request.proceed(socket, this)
|
val channel = request.proceed(socket, this)
|
||||||
Response.from(channel, viewModelScope)
|
Response.from(channel, viewModelScope)
|
||||||
|
@ -79,8 +111,10 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
|
||||||
)
|
)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isActive)
|
if (!isActive)
|
||||||
return@launch
|
return@launch
|
||||||
|
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
signalError("Can't parse server response.")
|
signalError("Can't parse server response.")
|
||||||
return@launch
|
return@launch
|
||||||
|
@ -113,9 +147,10 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
private suspend fun handleSuccessResponse(response: Response, uri: Uri) {
|
private suspend fun handleSuccessResponse(response: Response, uri: Uri) {
|
||||||
state.postValue(State.RECEIVING)
|
state.postValue(State.RECEIVING)
|
||||||
|
val uriString = uri.toString()
|
||||||
|
|
||||||
linesList.clear()
|
linesList.clear()
|
||||||
lines.postValue(linesList)
|
lines.postValue(Pair(uriString, linesList))
|
||||||
val charset = Charset.defaultCharset()
|
val charset = Charset.defaultCharset()
|
||||||
var mainTitle: String? = null
|
var mainTitle: String? = null
|
||||||
var lastUpdate = System.currentTimeMillis()
|
var lastUpdate = System.currentTimeMillis()
|
||||||
|
@ -126,7 +161,16 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
|
||||||
while (!lineChannel.isClosedForReceive) {
|
while (!lineChannel.isClosedForReceive) {
|
||||||
val lineChannelResult = withTimeout(100) { lineChannel.tryReceive() }
|
val lineChannelResult = withTimeout(100) { lineChannel.tryReceive() }
|
||||||
lineChannelResult.onSuccess { line ->
|
lineChannelResult.onSuccess { line ->
|
||||||
|
if (line is LinkLine) {
|
||||||
|
// Mark visited links here as we have a access to the history.
|
||||||
|
val fullUrl =
|
||||||
|
if (Uri.parse(line.url).isAbsolute) line.url
|
||||||
|
else joinUrls(uriString, line.url).toString()
|
||||||
|
if (History.contains(fullUrl))
|
||||||
|
line.visited = true
|
||||||
|
}
|
||||||
linesList.add(line)
|
linesList.add(line)
|
||||||
|
// Get the first level 1 header as the page main title.
|
||||||
if (mainTitle == null && line is TitleLine && line.level == 1)
|
if (mainTitle == null && line is TitleLine && line.level == 1)
|
||||||
mainTitle = line.text
|
mainTitle = line.text
|
||||||
}
|
}
|
||||||
|
@ -135,7 +179,7 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
|
||||||
if (linesList.size > lastNumLines) {
|
if (linesList.size > lastNumLines) {
|
||||||
val time = System.currentTimeMillis()
|
val time = System.currentTimeMillis()
|
||||||
if (time - lastUpdate >= 100) {
|
if (time - lastUpdate >= 100) {
|
||||||
lines.postValue(linesList)
|
lines.postValue(Pair(uriString, linesList))
|
||||||
lastUpdate = time
|
lastUpdate = time
|
||||||
lastNumLines = linesList.size
|
lastNumLines = linesList.size
|
||||||
}
|
}
|
||||||
|
@ -147,7 +191,7 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Log.d(TAG, "handleSuccessResponse: done parsing line data")
|
Log.d(TAG, "handleSuccessResponse: done parsing line data")
|
||||||
lines.postValue(linesList)
|
lines.postValue(Pair(uriString, linesList))
|
||||||
|
|
||||||
// We record the history entry here: it's nice because we have the main title available
|
// We record the history entry here: it's nice because we have the main title available
|
||||||
// and we're already in a coroutine for database access.
|
// and we're already in a coroutine for database access.
|
||||||
|
|
|
@ -8,20 +8,25 @@ import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.BufferedInputStream
|
import java.io.BufferedInputStream
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.Principal
|
||||||
|
import java.security.PrivateKey
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.*
|
||||||
import javax.net.ssl.SSLProtocolException
|
|
||||||
import javax.net.ssl.SSLSocket
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
class Request(private val uri: Uri) {
|
class Request(private val uri: Uri, private val keyManager: KeyManager? = null) {
|
||||||
private val port get() = if (uri.port > 0) uri.port else 1965
|
private val port get() = if (uri.port > 0) uri.port else 1965
|
||||||
|
|
||||||
fun connect(protocol: String, connectionTimeout: Int, readTimeout: Int): SSLSocket {
|
fun connect(protocol: String, connectionTimeout: Int, readTimeout: Int): SSLSocket {
|
||||||
Log.d(TAG, "connect: $protocol, c.to. $connectionTimeout, r.to. $readTimeout")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"connect: $protocol, conn. timeout $connectionTimeout," +
|
||||||
|
" read timeout $readTimeout, key manager $keyManager"
|
||||||
|
)
|
||||||
val context = SSLContext.getInstance(protocol)
|
val context = SSLContext.getInstance(protocol)
|
||||||
context.init(null, arrayOf(TrustManager()), null)
|
context.init(arrayOf(keyManager), arrayOf(TrustManager()), null)
|
||||||
val socket = context.socketFactory.createSocket() as SSLSocket
|
val socket = context.socketFactory.createSocket() as SSLSocket
|
||||||
socket.soTimeout = readTimeout * 1000
|
socket.soTimeout = readTimeout * 1000
|
||||||
socket.connect(InetSocketAddress(uri.host, port), connectionTimeout * 1000)
|
socket.connect(InetSocketAddress(uri.host, port), connectionTimeout * 1000)
|
||||||
|
@ -58,6 +63,49 @@ class Request(private val uri: Uri) {
|
||||||
return channel
|
return channel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class KeyManager(
|
||||||
|
private val alias: String,
|
||||||
|
private val cert: X509Certificate,
|
||||||
|
private val privateKey: PrivateKey
|
||||||
|
) : X509ExtendedKeyManager() {
|
||||||
|
companion object {
|
||||||
|
fun fromAlias(alias: String): KeyManager? {
|
||||||
|
val cert = Identities.keyStore.getCertificate(alias) as X509Certificate?
|
||||||
|
?: return null.also { Log.e(TAG, "fromAlias: cert is null") }
|
||||||
|
val key = Identities.keyStore.getEntry(alias, null)?.let { entry ->
|
||||||
|
(entry as KeyStore.PrivateKeyEntry).privateKey
|
||||||
|
} ?: return null.also { Log.e(TAG, "fromAlias: private key is null") }
|
||||||
|
return KeyManager(alias, cert, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chooseClientAlias(
|
||||||
|
keyType: Array<out String>?,
|
||||||
|
issuers: Array<out Principal>?,
|
||||||
|
socket: Socket?
|
||||||
|
): String = alias
|
||||||
|
|
||||||
|
override fun getCertificateChain(alias: String?): Array<out X509Certificate> = arrayOf(cert)
|
||||||
|
|
||||||
|
override fun getPrivateKey(alias: String?): PrivateKey = privateKey
|
||||||
|
|
||||||
|
override fun getServerAliases(
|
||||||
|
keyType: String?, issuers: Array<out Principal>?
|
||||||
|
): Array<String> = throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
override fun chooseServerAlias(
|
||||||
|
keyType: String?,
|
||||||
|
issuers: Array<out Principal>?,
|
||||||
|
socket: Socket?
|
||||||
|
): String = throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
override fun getClientAliases(
|
||||||
|
keyType: String?,
|
||||||
|
issuers: Array<out Principal>?
|
||||||
|
): Array<String> = throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TODO An X509TrustManager implementation for TOFU validation. */
|
||||||
@SuppressLint("CustomX509TrustManager")
|
@SuppressLint("CustomX509TrustManager")
|
||||||
class TrustManager : X509TrustManager {
|
class TrustManager : X509TrustManager {
|
||||||
@SuppressLint("TrustAllX509TrustManager")
|
@SuppressLint("TrustAllX509TrustManager")
|
||||||
|
|
|
@ -18,16 +18,33 @@
|
||||||
android:fontFamily="@font/preformatted"
|
android:fontFamily="@font/preformatted"
|
||||||
android:typeface="monospace" />
|
android:typeface="monospace" />
|
||||||
|
|
||||||
<TextView
|
<RelativeLayout
|
||||||
android:id="@+id/titleText"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="16sp"
|
|
||||||
android:textColor="@color/text"
|
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="8dp"
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="8dp"
|
||||||
android:layout_marginTop="0dp"
|
android:layout_marginTop="0dp"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp">
|
||||||
android:textAppearance="?attr/textAppearanceListItem" />
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/titleText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_toStartOf="@id/lastVisitText"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textColor="@color/text" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/lastVisitText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_alignBaseline="@id/titleText"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="italic"
|
||||||
|
android:textColor="@color/text_light" />
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
|
@ -1,6 +1,25 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:id="@+id/text_view"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content">
|
||||||
style="@style/CometLink" />
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/text_view"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_toStartOf="@id/protocol_view"
|
||||||
|
style="@style/CometLink" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/protocol_view"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignBaseline="@id/text_view"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
style="@style/CometText"
|
||||||
|
android:textColor="@color/text_light"
|
||||||
|
android:textStyle="italic"
|
||||||
|
android:textIsSelectable="false" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
<color name="main_accent_dark">#073642</color>
|
<color name="main_accent_dark">#073642</color>
|
||||||
<color name="second_accent">#2aa198</color>
|
<color name="second_accent">#2aa198</color>
|
||||||
<color name="second_accent_dark">#073642</color>
|
<color name="second_accent_dark">#073642</color>
|
||||||
<color name="link">#2aa198</color>
|
|
||||||
<color name="url_bar">#fdf6e3</color>
|
<color name="url_bar">#fdf6e3</color>
|
||||||
<color name="url_bar_loading">#586e75</color>
|
<color name="url_bar_loading">#586e75</color>
|
||||||
</resources>
|
</resources>
|
|
@ -11,6 +11,7 @@
|
||||||
<color name="second_accent">#6c71c4</color>
|
<color name="second_accent">#6c71c4</color>
|
||||||
<color name="second_accent_dark">#073642</color>
|
<color name="second_accent_dark">#073642</color>
|
||||||
<color name="link">#268bd2</color>
|
<color name="link">#268bd2</color>
|
||||||
|
<color name="link_visited">#2aa198</color>
|
||||||
<color name="url_bar">#002b36</color>
|
<color name="url_bar">#002b36</color>
|
||||||
<color name="url_bar_loading">#fdf6e3</color>
|
<color name="url_bar_loading">#fdf6e3</color>
|
||||||
<!--
|
<!--
|
||||||
|
|
Reference in a new issue