Compare commits

...

6 commits

18 changed files with 176 additions and 121 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "70da3095877de4a82021855471523b90",
"identityHash": "ffa6a7f7cce2d67541e0fbe28441e780",
"entities": [
{
"tableName": "HistoryEntry",
@ -38,7 +38,7 @@
},
{
"tableName": "Identity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `key` TEXT NOT NULL, `name` TEXT)",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `key` TEXT NOT NULL, `name` TEXT, `urls` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
@ -57,38 +57,12 @@
"columnName": "name",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "IdentityUsage",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uri` TEXT NOT NULL, `identityId` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uri",
"columnName": "uri",
"fieldPath": "urls",
"columnName": "urls",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "identityId",
"columnName": "identityId",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
@ -104,7 +78,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '70da3095877de4a82021855471523b90')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ffa6a7f7cce2d67541e0fbe28441e780')"
]
}
}

View file

@ -1,18 +1,18 @@
package dev.lowrespalmtree.comet
import android.content.Context
import android.util.Base64
import androidx.room.*
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [
History.HistoryEntry::class,
Identities.Identity::class,
Identities.IdentityUsage::class,
],
version = 1
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun historyEntryDao(): History.HistoryEntryDao
abstract fun identityDao(): Identities.IdentityDao
@ -27,3 +27,17 @@ object Database {
INSTANCE = Room.databaseBuilder(context, AppDatabase::class.java, "comet.db").build()
}
}
typealias UrlList = ArrayList<String>
class Converters {
@TypeConverter
fun fromUrlList(value: UrlList?): String? =
value?.joinToString("-") {
Base64.encodeToString(it.encodeToByteArray(), Base64.DEFAULT)
}
@TypeConverter
fun stringToUrlList(value: String?): UrlList? =
value?.split("-")?.map { Base64.decode(it, Base64.DEFAULT).decodeToString() } as UrlList?
}

View file

@ -4,8 +4,10 @@ import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Log
import androidx.room.*
import java.lang.IllegalArgumentException
import java.security.KeyPairGenerator
import java.security.KeyStore
import javax.security.auth.x500.X500Principal
object Identities {
@Entity
@ -16,16 +18,8 @@ object Identities {
val key: String,
/** Label for this identity. */
var name: String?,
)
@Entity
data class IdentityUsage(
/** ID. */
@PrimaryKey(autoGenerate = true) val id: Int,
/** URL path where an identity can be used. */
val uri: String,
/** ID of the Identity to use. */
val identityId: Int
/** URL paths configured to use this identity. */
var urls: UrlList
)
@Dao
@ -42,15 +36,12 @@ object Identities {
@Update
suspend fun update(vararg identities: Identity)
@Query("SELECT * FROM IdentityUsage WHERE :identityId = identityId")
suspend fun getUsagesFor(identityId: Int): List<IdentityUsage>
@Delete
suspend fun delete(vararg identities: Identity)
}
suspend fun insert(key: String, name: String? = null): Long =
Database.INSTANCE.identityDao().insert(Identity(0, key, name))
Database.INSTANCE.identityDao().insert(Identity(0, key, name, arrayListOf()))
suspend fun get(id: Long): Identity? =
Database.INSTANCE.identityDao().get(id)
@ -69,11 +60,20 @@ object Identities {
Database.INSTANCE.identityDao().delete(*identities)
}
fun generateClientCert(alias: String) {
fun generateClientCert(alias: String, commonName: String) {
val algo = KeyProperties.KEY_ALGORITHM_RSA
val kpg = KeyPairGenerator.getInstance(algo, "AndroidKeyStore")
val purposes = KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
val spec = KeyGenParameterSpec.Builder(alias, purposes)
.apply {
if (commonName.isNotEmpty()) {
try {
setCertificateSubject(X500Principal("CN=$commonName"))
} catch (e: IllegalArgumentException) {
Log.e(TAG, "generateClientCert: bad common name: ${e.message}")
}
}
}
.setDigests(KeyProperties.DIGEST_SHA256)
.build()
kpg.initialize(spec)
@ -83,7 +83,6 @@ object Identities {
private fun deleteClientCert(alias: String) {
val keyStore = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
Log.i(TAG, keyStore.aliases().toList().joinToString { it })
if (keyStore.containsAlias(alias)) {
keyStore.deleteEntry(alias)
Log.i(TAG, "deleteClientCert: deleted entry with alias \"$alias\"")

View file

@ -35,7 +35,7 @@ class IdentitiesAdapter(private val listener: Listener) :
listener.onIdentityClick(item)
}
holder.binding.container.setOnLongClickListener {
listener.onIdentityLongClick(item, holder.itemView);
listener.onIdentityLongClick(item, holder.itemView)
true
}
}

View file

@ -20,7 +20,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialog.Listener {
class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityEditDialog.Listener {
private val vm: IdentitiesViewModel by viewModels()
private lateinit var binding: FragmentIdentitiesBinding
private lateinit var adapter: IdentitiesAdapter
@ -41,7 +41,7 @@ class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialo
adapter = IdentitiesAdapter(this)
binding.list.adapter = adapter
binding.floatingActionButton.setOnClickListener { openNewIdentityEditor() }
binding.floatingActionButton.setOnClickListener { openIdentityWizard() }
vm.identities.observe(viewLifecycleOwner) { adapter.setIdentities(it) }
@ -49,7 +49,7 @@ class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialo
}
override fun onIdentityClick(identity: Identity) {
IdentityDialog(requireContext(), identity, this).show()
IdentityEditDialog(requireContext(), identity, this).show()
}
override fun onIdentityLongClick(identity: Identity, view: View) {
@ -59,7 +59,7 @@ class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialo
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.item_edit -> {
IdentityDialog(
IdentityEditDialog(
requireContext(),
identity,
this@IdentitiesFragment
@ -82,27 +82,39 @@ class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialo
vm.saveIdentity(identity)
}
private fun openNewIdentityEditor() {
/**
* Open the new identity wizard.
*
* There is a first dialog to ask the user about the desired subject common name,
* then the certificate is generated and the edition dialog is opened.
*/
private fun openIdentityWizard() {
InputDialog(requireContext(), getString(R.string.input_common_name))
.show(
onOk = { text ->
toast(requireContext(), R.string.generating_keypair)
vm.newIdentity.observe(viewLifecycleOwner) { identity ->
if (identity == null)
return@observe
vm.newIdentity.removeObservers(viewLifecycleOwner)
vm.newIdentity.value = null
IdentityDialog(requireContext(), identity, this).show()
IdentityEditDialog(requireContext(), identity, this).show()
}
vm.createNewIdentity()
vm.createNewIdentity(text)
},
onDismiss = {}
)
}
class IdentitiesViewModel : ViewModel() {
val identities: MutableLiveData<List<Identity>> by lazy { MutableLiveData<List<Identity>>() }
val newIdentity: MutableLiveData<Identity> by lazy { MutableLiveData<Identity>() }
fun createNewIdentity() {
fun createNewIdentity(commonName: String) {
viewModelScope.launch(Dispatchers.IO) {
val alias = "identity-${UUID.randomUUID()}"
Identities.generateClientCert(alias)
val newIdentityId = Identities.insert(alias)
Identities.generateClientCert(alias, commonName)
val newIdentityId = Identities.insert(alias, commonName)
newIdentity.postValue(Identities.get(newIdentityId))
}
.invokeOnCompletion { refreshIdentities() }

View file

@ -5,7 +5,7 @@ import android.content.Context
import android.view.LayoutInflater
import dev.lowrespalmtree.comet.databinding.DialogIdentityBinding
class IdentityDialog(
class IdentityEditDialog(
private val context: Context,
private val identity: Identities.Identity,
private val listener: Listener
@ -19,12 +19,14 @@ class IdentityDialog(
fun show() {
binding = DialogIdentityBinding.inflate(LayoutInflater.from(context))
binding.labelInput.setText(identity.name.orEmpty())
binding.urlInput.setText(identity.urls.getOrNull(0).orEmpty())
binding.aliasText.text = identity.key
AlertDialog.Builder(context)
.setTitle(R.string.edit_identity)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
identity.name = binding.labelInput.text.toString()
identity.urls = arrayListOf(binding.urlInput.text.toString())
listener.onSaveIdentity(identity)
}
.create()

View file

@ -0,0 +1,23 @@
package dev.lowrespalmtree.comet
import android.app.AlertDialog
import android.content.Context
import android.view.LayoutInflater
import dev.lowrespalmtree.comet.databinding.DialogInputBinding
/** Generic text input dialog. Used for code 10 and a few other simple text input. */
class InputDialog(
private val context: Context,
private val prompt: String
) {
fun show(onOk: (text: String) -> Unit, onDismiss: () -> Unit) {
val binding = DialogInputBinding.inflate(LayoutInflater.from(context))
AlertDialog.Builder(context)
.setMessage(prompt)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ -> onOk(binding.textInput.text.toString()) }
.setOnDismissListener { onDismiss() }
.create()
.show()
}
}

View file

@ -5,19 +5,15 @@ 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 android.widget.TextView
import androidx.activity.addCallback
import androidx.appcompat.app.AlertDialog
import androidx.core.view.setMargins
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.preference.PreferenceManager
@ -122,11 +118,13 @@ class PageFragment : Fragment(), PageAdapter.Listener {
when (uri.scheme) {
"gemini" -> {
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, connectionTimeout, readTimeout)
vm.sendGeminiRequest(uri, protocol, connectionTimeout, readTimeout)
}
else -> openUnknownScheme(uri)
}
@ -196,29 +194,14 @@ class PageFragment : Fragment(), PageAdapter.Listener {
}
private fun askForInput(prompt: String, uri: Uri) {
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(prompt.ifEmpty { "Input required" })
.setView(inputView)
.setPositiveButton(android.R.string.ok) { _, _ ->
val newUri = uri.buildUpon().query(editText.text.toString()).build()
InputDialog(requireContext(), prompt.ifEmpty { "Input required" })
.show(
onOk = { text ->
val newUri = uri.buildUpon().query(text).build()
openUrl(newUri.toString(), base = vm.currentUrl)
}
.setOnDismissListener { updateState(PageViewModel.State.IDLE) }
.create()
.show()
},
onDismiss = { updateState(PageViewModel.State.IDLE) }
)
}
private fun openUnknownScheme(uri: Uri) {

View file

@ -52,7 +52,7 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
* The URI must be valid, absolute and with a gemini scheme.
*/
@ExperimentalCoroutinesApi
fun sendGeminiRequest(uri: Uri, connectionTimeout: Int, readTimeout: Int, redirects: Int = 0) {
fun sendGeminiRequest(uri: Uri, protocol: String, connectionTimeout: Int, readTimeout: Int, redirects: Int = 0) {
Log.d(TAG, "sendRequest: URI \"$uri\"")
loadingUrl = uri
state.postValue(State.CONNECTING)
@ -60,7 +60,7 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
requestJob = viewModelScope.launch(Dispatchers.IO) {
val response = try {
val request = Request(uri)
val socket = request.connect(connectionTimeout, readTimeout)
val socket = request.connect(protocol, connectionTimeout, readTimeout)
val channel = request.proceed(socket, this)
Response.from(channel, viewModelScope)
} catch (e: Exception) {
@ -71,7 +71,7 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
signalError(
when (e) {
is UnknownHostException -> "Unknown host \"${uri.authority}\"."
is ConnectException -> "Can't connect to this server: ${e.localizedMessage}."
is ConnectException -> "Can't connect to this server: ${e.message}."
is SocketTimeoutException -> "Connection timed out."
is CancellationException -> "Connection cancelled: ${e.message}."
else -> "Oops, something failed!"

View file

@ -17,9 +17,9 @@ import javax.net.ssl.X509TrustManager
class Request(private val uri: Uri) {
private val port get() = if (uri.port > 0) uri.port else 1965
fun connect(connectionTimeout: Int, readTimeout: Int): SSLSocket {
Log.d(TAG, "connect")
val context = SSLContext.getInstance("TLSv1.2")
fun connect(protocol: String, connectionTimeout: Int, readTimeout: Int): SSLSocket {
Log.d(TAG, "connect: $protocol, c.to. $connectionTimeout, r.to. $readTimeout")
val context = SSLContext.getInstance(protocol)
context.init(null, arrayOf(TrustManager()), null)
val socket = context.socketFactory.createSocket() as SSLSocket
socket.soTimeout = readTimeout * 1000
@ -70,6 +70,7 @@ class Request(private val uri: Uri) {
companion object {
private const val TAG = "Request"
const val DEFAULT_TLS_VERSION = "TLSv1.3"
const val DEFAULT_CONNECTION_TIMEOUT_SEC = 10
const val DEFAULT_READ_TIMEOUT_SEC = 10
}

View file

@ -28,6 +28,7 @@ fun confirm(context: Context, @StringRes prompt: Int, onOk: () -> Unit) {
.setTitle(R.string.confirm)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ -> onOk() }
.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() }
.create()
.show()
}

View file

@ -8,6 +8,6 @@
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16sp" />
style="@style/CometText" />
</LinearLayout>

View file

@ -3,12 +3,14 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/identity_name" />
android:text="@string/identity_name"
style="@style/CometLabel" />
<EditText
android:id="@+id/labelInput"
@ -18,10 +20,33 @@
android:importantForAutofill="no"
android:inputType="text" />
<Space
android:layout_width="wrap_content"
android:layout_height="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/identity_alias_title" />
android:text="@string/identity_usages"
style="@style/CometLabel" />
<EditText
android:id="@+id/urlInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/url"
android:inputType="textUri"
android:importantForAutofill="no" />
<Space
android:layout_width="wrap_content"
android:layout_height="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/identity_alias_title"
style="@style/CometLabel" />
<TextView
android:id="@+id/aliasText"

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<EditText
android:id="@+id/text_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:inputType="text" />
</LinearLayout>

View file

@ -1,12 +1,11 @@
<resources>
<!-- Reply Preference -->
<string-array name="reply_entries">
<item>Reply</item>
<item>Reply to all</item>
<string-array name="tls_version_entries">
<item>TLS v1.3</item>
<item>TLS v1.2</item>
</string-array>
<string-array name="reply_values">
<item>reply</item>
<item>reply_all</item>
<string-array name="tls_version_values">
<item>TLSv1.3</item>
<item>TLSv1.2</item>
</string-array>
</resources>

View file

@ -44,5 +44,8 @@
<string name="confirm">Confirm</string>
<string name="confirm_identity_delete">Are you sure you want to delete this identity? The client certificate cannot be retrieved afterwards.</string>
<string name="edit">Edit</string>
<string name="identity_usages">Active URL path</string>
<string name="input_common_name">Enter a name to use as the certificate\'s subject common name. This can be left empty.</string>
<string name="tls_version">TLS version</string>
</resources>

View file

@ -14,4 +14,9 @@
<item name="android:textIsSelectable">false</item>
<item name="android:background">?attr/selectableItemBackground</item>
</style>
<style name="CometLabel">
<item name="android:textSize">16sp</item>
<item name="android:textColor">@color/main_accent</item>
<item name="android:paddingTop">2dp</item>
</style>
</resources>

View file

@ -13,18 +13,18 @@
app:key="home_set"
app:title="@string/pref_home_set" />
<ListPreference
app:defaultValue="reply"
app:entries="@array/reply_entries"
app:entryValues="@array/reply_values"
app:key="reply"
app:title="@string/reply_title"
app:useSimpleSummaryProvider="true" />
</PreferenceCategory>
<PreferenceCategory app:title="@string/pref_protocol_header">
<DropDownPreference
app:key="tls_version"
app:entries="@array/tls_version_entries"
app:entryValues="@array/tls_version_values"
app:defaultValue="TLSv1.3"
app:useSimpleSummaryProvider="true"
app:title="@string/tls_version" />
<SeekBarPreference
android:defaultValue="10"
android:max="60"