Compare commits

..

4 commits

Author SHA1 Message Date
dece 50793d1440 PageViewModel: use appropriate client certs 2022-02-13 23:56:09 +01:00
dece 3aea6f42c9 HistoryAdapter: show last visits 2022-02-13 19:10:55 +01:00
dece c4d0e66322 PageAdapter: show URL schemes on links
Currently only show them on absolute URLs and if they are not Gemini
links (https, gopher, etc).
2022-02-13 18:14:53 +01:00
dece 8c1bebf315 PageAdapter: show visited links with another color 2022-02-13 18:13:32 +01:00
13 changed files with 221 additions and 60 deletions

View file

@ -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,

View file

@ -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()

View file

@ -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

View file

@ -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"
} }

View file

@ -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"
}
} }

View file

@ -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

View file

@ -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 = {}
) )
} }

View file

@ -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.

View file

@ -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")

View file

@ -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>

View file

@ -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">
<TextView
android:id="@+id/text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_toStartOf="@id/protocol_view"
style="@style/CometLink" /> 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>

View file

@ -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>

View file

@ -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>
<!-- <!--