Compare commits
6 commits
11bea0e585
...
4282567f5f
Author | SHA1 | Date | |
---|---|---|---|
dece | 4282567f5f | ||
dece | 1d69c075a1 | ||
dece | b6d855fdeb | ||
dece | 02ba3a1401 | ||
dece | ea6f54cd73 | ||
dece | 9a91c72d1e |
|
@ -2,7 +2,7 @@
|
||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"database": {
|
"database": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"identityHash": "70da3095877de4a82021855471523b90",
|
"identityHash": "ffa6a7f7cce2d67541e0fbe28441e780",
|
||||||
"entities": [
|
"entities": [
|
||||||
{
|
{
|
||||||
"tableName": "HistoryEntry",
|
"tableName": "HistoryEntry",
|
||||||
|
@ -38,7 +38,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tableName": "Identity",
|
"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": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"fieldPath": "id",
|
"fieldPath": "id",
|
||||||
|
@ -57,38 +57,12 @@
|
||||||
"columnName": "name",
|
"columnName": "name",
|
||||||
"affinity": "TEXT",
|
"affinity": "TEXT",
|
||||||
"notNull": false
|
"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",
|
"fieldPath": "urls",
|
||||||
"columnName": "uri",
|
"columnName": "urls",
|
||||||
"affinity": "TEXT",
|
"affinity": "TEXT",
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "identityId",
|
|
||||||
"columnName": "identityId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"primaryKey": {
|
"primaryKey": {
|
||||||
|
@ -104,7 +78,7 @@
|
||||||
"views": [],
|
"views": [],
|
||||||
"setupQueries": [
|
"setupQueries": [
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
"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')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,18 +1,18 @@
|
||||||
package dev.lowrespalmtree.comet
|
package dev.lowrespalmtree.comet
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.room.*
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.Room
|
|
||||||
import androidx.room.RoomDatabase
|
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
History.HistoryEntry::class,
|
History.HistoryEntry::class,
|
||||||
Identities.Identity::class,
|
Identities.Identity::class,
|
||||||
Identities.IdentityUsage::class,
|
|
||||||
],
|
],
|
||||||
version = 1
|
version = 1
|
||||||
)
|
)
|
||||||
|
@TypeConverters(Converters::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun historyEntryDao(): History.HistoryEntryDao
|
abstract fun historyEntryDao(): History.HistoryEntryDao
|
||||||
abstract fun identityDao(): Identities.IdentityDao
|
abstract fun identityDao(): Identities.IdentityDao
|
||||||
|
@ -26,4 +26,18 @@ object Database {
|
||||||
return
|
return
|
||||||
INSTANCE = Room.databaseBuilder(context, AppDatabase::class.java, "comet.db").build()
|
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?
|
||||||
}
|
}
|
|
@ -4,8 +4,10 @@ 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
|
||||||
|
|
||||||
object Identities {
|
object Identities {
|
||||||
@Entity
|
@Entity
|
||||||
|
@ -16,16 +18,8 @@ object Identities {
|
||||||
val key: String,
|
val key: String,
|
||||||
/** Label for this identity. */
|
/** Label for this identity. */
|
||||||
var name: String?,
|
var name: String?,
|
||||||
)
|
/** URL paths configured to use this identity. */
|
||||||
|
var urls: UrlList
|
||||||
@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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
|
@ -42,15 +36,12 @@ object Identities {
|
||||||
@Update
|
@Update
|
||||||
suspend fun update(vararg identities: Identity)
|
suspend fun update(vararg identities: Identity)
|
||||||
|
|
||||||
@Query("SELECT * FROM IdentityUsage WHERE :identityId = identityId")
|
|
||||||
suspend fun getUsagesFor(identityId: Int): List<IdentityUsage>
|
|
||||||
|
|
||||||
@Delete
|
@Delete
|
||||||
suspend fun delete(vararg identities: Identity)
|
suspend fun delete(vararg identities: Identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun insert(key: String, name: String? = null): Long =
|
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? =
|
suspend fun get(id: Long): Identity? =
|
||||||
Database.INSTANCE.identityDao().get(id)
|
Database.INSTANCE.identityDao().get(id)
|
||||||
|
@ -69,11 +60,20 @@ object Identities {
|
||||||
Database.INSTANCE.identityDao().delete(*identities)
|
Database.INSTANCE.identityDao().delete(*identities)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun generateClientCert(alias: 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, "AndroidKeyStore")
|
||||||
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 {
|
||||||
|
if (commonName.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
setCertificateSubject(X500Principal("CN=$commonName"))
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
Log.e(TAG, "generateClientCert: bad common name: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.setDigests(KeyProperties.DIGEST_SHA256)
|
.setDigests(KeyProperties.DIGEST_SHA256)
|
||||||
.build()
|
.build()
|
||||||
kpg.initialize(spec)
|
kpg.initialize(spec)
|
||||||
|
@ -83,7 +83,6 @@ object Identities {
|
||||||
|
|
||||||
private fun deleteClientCert(alias: String) {
|
private fun deleteClientCert(alias: String) {
|
||||||
val keyStore = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
|
val keyStore = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
|
||||||
Log.i(TAG, keyStore.aliases().toList().joinToString { it })
|
|
||||||
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\"")
|
||||||
|
|
|
@ -35,7 +35,7 @@ class IdentitiesAdapter(private val listener: Listener) :
|
||||||
listener.onIdentityClick(item)
|
listener.onIdentityClick(item)
|
||||||
}
|
}
|
||||||
holder.binding.container.setOnLongClickListener {
|
holder.binding.container.setOnLongClickListener {
|
||||||
listener.onIdentityLongClick(item, holder.itemView);
|
listener.onIdentityLongClick(item, holder.itemView)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialog.Listener {
|
class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityEditDialog.Listener {
|
||||||
private val vm: IdentitiesViewModel by viewModels()
|
private val vm: IdentitiesViewModel by viewModels()
|
||||||
private lateinit var binding: FragmentIdentitiesBinding
|
private lateinit var binding: FragmentIdentitiesBinding
|
||||||
private lateinit var adapter: IdentitiesAdapter
|
private lateinit var adapter: IdentitiesAdapter
|
||||||
|
@ -41,7 +41,7 @@ class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialo
|
||||||
adapter = IdentitiesAdapter(this)
|
adapter = IdentitiesAdapter(this)
|
||||||
binding.list.adapter = adapter
|
binding.list.adapter = adapter
|
||||||
|
|
||||||
binding.floatingActionButton.setOnClickListener { openNewIdentityEditor() }
|
binding.floatingActionButton.setOnClickListener { openIdentityWizard() }
|
||||||
|
|
||||||
vm.identities.observe(viewLifecycleOwner) { adapter.setIdentities(it) }
|
vm.identities.observe(viewLifecycleOwner) { adapter.setIdentities(it) }
|
||||||
|
|
||||||
|
@ -49,7 +49,7 @@ class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialo
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onIdentityClick(identity: Identity) {
|
override fun onIdentityClick(identity: Identity) {
|
||||||
IdentityDialog(requireContext(), identity, this).show()
|
IdentityEditDialog(requireContext(), identity, this).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onIdentityLongClick(identity: Identity, view: View) {
|
override fun onIdentityLongClick(identity: Identity, view: View) {
|
||||||
|
@ -59,7 +59,7 @@ class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialo
|
||||||
setOnMenuItemClickListener { item ->
|
setOnMenuItemClickListener { item ->
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
R.id.item_edit -> {
|
R.id.item_edit -> {
|
||||||
IdentityDialog(
|
IdentityEditDialog(
|
||||||
requireContext(),
|
requireContext(),
|
||||||
identity,
|
identity,
|
||||||
this@IdentitiesFragment
|
this@IdentitiesFragment
|
||||||
|
@ -82,27 +82,39 @@ class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialo
|
||||||
vm.saveIdentity(identity)
|
vm.saveIdentity(identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openNewIdentityEditor() {
|
/**
|
||||||
toast(requireContext(), R.string.generating_keypair)
|
* Open the new identity wizard.
|
||||||
vm.newIdentity.observe(viewLifecycleOwner) { identity ->
|
*
|
||||||
if (identity == null)
|
* There is a first dialog to ask the user about the desired subject common name,
|
||||||
return@observe
|
* then the certificate is generated and the edition dialog is opened.
|
||||||
vm.newIdentity.removeObservers(viewLifecycleOwner)
|
*/
|
||||||
vm.newIdentity.value = null
|
private fun openIdentityWizard() {
|
||||||
IdentityDialog(requireContext(), identity, this).show()
|
InputDialog(requireContext(), getString(R.string.input_common_name))
|
||||||
}
|
.show(
|
||||||
vm.createNewIdentity()
|
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
|
||||||
|
IdentityEditDialog(requireContext(), identity, this).show()
|
||||||
|
}
|
||||||
|
vm.createNewIdentity(text)
|
||||||
|
},
|
||||||
|
onDismiss = {}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
class IdentitiesViewModel : ViewModel() {
|
class IdentitiesViewModel : ViewModel() {
|
||||||
val identities: MutableLiveData<List<Identity>> by lazy { MutableLiveData<List<Identity>>() }
|
val identities: MutableLiveData<List<Identity>> by lazy { MutableLiveData<List<Identity>>() }
|
||||||
val newIdentity: MutableLiveData<Identity> by lazy { MutableLiveData<Identity>() }
|
val newIdentity: MutableLiveData<Identity> by lazy { MutableLiveData<Identity>() }
|
||||||
|
|
||||||
fun createNewIdentity() {
|
fun createNewIdentity(commonName: String) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val alias = "identity-${UUID.randomUUID()}"
|
val alias = "identity-${UUID.randomUUID()}"
|
||||||
Identities.generateClientCert(alias)
|
Identities.generateClientCert(alias, commonName)
|
||||||
val newIdentityId = Identities.insert(alias)
|
val newIdentityId = Identities.insert(alias, commonName)
|
||||||
newIdentity.postValue(Identities.get(newIdentityId))
|
newIdentity.postValue(Identities.get(newIdentityId))
|
||||||
}
|
}
|
||||||
.invokeOnCompletion { refreshIdentities() }
|
.invokeOnCompletion { refreshIdentities() }
|
||||||
|
|
|
@ -5,7 +5,7 @@ import android.content.Context
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import dev.lowrespalmtree.comet.databinding.DialogIdentityBinding
|
import dev.lowrespalmtree.comet.databinding.DialogIdentityBinding
|
||||||
|
|
||||||
class IdentityDialog(
|
class IdentityEditDialog(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val identity: Identities.Identity,
|
private val identity: Identities.Identity,
|
||||||
private val listener: Listener
|
private val listener: Listener
|
||||||
|
@ -19,12 +19,14 @@ class IdentityDialog(
|
||||||
fun show() {
|
fun show() {
|
||||||
binding = DialogIdentityBinding.inflate(LayoutInflater.from(context))
|
binding = DialogIdentityBinding.inflate(LayoutInflater.from(context))
|
||||||
binding.labelInput.setText(identity.name.orEmpty())
|
binding.labelInput.setText(identity.name.orEmpty())
|
||||||
|
binding.urlInput.setText(identity.urls.getOrNull(0).orEmpty())
|
||||||
binding.aliasText.text = identity.key
|
binding.aliasText.text = identity.key
|
||||||
AlertDialog.Builder(context)
|
AlertDialog.Builder(context)
|
||||||
.setTitle(R.string.edit_identity)
|
.setTitle(R.string.edit_identity)
|
||||||
.setView(binding.root)
|
.setView(binding.root)
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
identity.name = binding.labelInput.text.toString()
|
identity.name = binding.labelInput.text.toString()
|
||||||
|
identity.urls = arrayListOf(binding.urlInput.text.toString())
|
||||||
listener.onSaveIdentity(identity)
|
listener.onSaveIdentity(identity)
|
||||||
}
|
}
|
||||||
.create()
|
.create()
|
23
app/src/main/java/dev/lowrespalmtree/comet/InputDialog.kt
Normal file
23
app/src/main/java/dev/lowrespalmtree/comet/InputDialog.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,19 +5,15 @@ import android.content.ActivityNotFoundException
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.InputType
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.inputmethod.EditorInfo
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.EditText
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.addCallback
|
import androidx.activity.addCallback
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.view.setMargins
|
|
||||||
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.preference.PreferenceManager
|
||||||
|
@ -122,11 +118,13 @@ class PageFragment : Fragment(), PageAdapter.Listener {
|
||||||
when (uri.scheme) {
|
when (uri.scheme) {
|
||||||
"gemini" -> {
|
"gemini" -> {
|
||||||
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||||
|
val protocol =
|
||||||
|
prefs.getString("tls_version", Request.DEFAULT_TLS_VERSION)!!
|
||||||
val connectionTimeout =
|
val connectionTimeout =
|
||||||
prefs.getInt("connection_timeout", Request.DEFAULT_CONNECTION_TIMEOUT_SEC)
|
prefs.getInt("connection_timeout", Request.DEFAULT_CONNECTION_TIMEOUT_SEC)
|
||||||
val readTimeout =
|
val readTimeout =
|
||||||
prefs.getInt("read_timeout", Request.DEFAULT_READ_TIMEOUT_SEC)
|
prefs.getInt("read_timeout", Request.DEFAULT_READ_TIMEOUT_SEC)
|
||||||
vm.sendGeminiRequest(uri, connectionTimeout, readTimeout)
|
vm.sendGeminiRequest(uri, protocol, connectionTimeout, readTimeout)
|
||||||
}
|
}
|
||||||
else -> openUnknownScheme(uri)
|
else -> openUnknownScheme(uri)
|
||||||
}
|
}
|
||||||
|
@ -196,29 +194,14 @@ class PageFragment : Fragment(), PageAdapter.Listener {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun askForInput(prompt: String, uri: Uri) {
|
private fun askForInput(prompt: String, uri: Uri) {
|
||||||
val editText = EditText(requireContext())
|
InputDialog(requireContext(), prompt.ifEmpty { "Input required" })
|
||||||
editText.inputType = InputType.TYPE_CLASS_TEXT
|
.show(
|
||||||
val inputView = FrameLayout(requireContext()).apply {
|
onOk = { text ->
|
||||||
addView(FrameLayout(requireContext()).apply {
|
val newUri = uri.buildUpon().query(text).build()
|
||||||
addView(editText)
|
openUrl(newUri.toString(), base = vm.currentUrl)
|
||||||
val params = FrameLayout.LayoutParams(
|
},
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
onDismiss = { updateState(PageViewModel.State.IDLE) }
|
||||||
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()
|
|
||||||
openUrl(newUri.toString(), base = vm.currentUrl)
|
|
||||||
}
|
|
||||||
.setOnDismissListener { updateState(PageViewModel.State.IDLE) }
|
|
||||||
.create()
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openUnknownScheme(uri: Uri) {
|
private fun openUnknownScheme(uri: Uri) {
|
||||||
|
|
|
@ -52,7 +52,7 @@ 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, 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\"")
|
Log.d(TAG, "sendRequest: URI \"$uri\"")
|
||||||
loadingUrl = uri
|
loadingUrl = uri
|
||||||
state.postValue(State.CONNECTING)
|
state.postValue(State.CONNECTING)
|
||||||
|
@ -60,7 +60,7 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
|
||||||
requestJob = viewModelScope.launch(Dispatchers.IO) {
|
requestJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
val response = try {
|
val response = try {
|
||||||
val request = Request(uri)
|
val request = Request(uri)
|
||||||
val socket = request.connect(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)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -71,7 +71,7 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
|
||||||
signalError(
|
signalError(
|
||||||
when (e) {
|
when (e) {
|
||||||
is UnknownHostException -> "Unknown host \"${uri.authority}\"."
|
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 SocketTimeoutException -> "Connection timed out."
|
||||||
is CancellationException -> "Connection cancelled: ${e.message}."
|
is CancellationException -> "Connection cancelled: ${e.message}."
|
||||||
else -> "Oops, something failed!"
|
else -> "Oops, something failed!"
|
||||||
|
|
|
@ -17,9 +17,9 @@ import javax.net.ssl.X509TrustManager
|
||||||
class Request(private val uri: Uri) {
|
class Request(private val uri: Uri) {
|
||||||
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(connectionTimeout: Int, readTimeout: Int): SSLSocket {
|
fun connect(protocol: String, connectionTimeout: Int, readTimeout: Int): SSLSocket {
|
||||||
Log.d(TAG, "connect")
|
Log.d(TAG, "connect: $protocol, c.to. $connectionTimeout, r.to. $readTimeout")
|
||||||
val context = SSLContext.getInstance("TLSv1.2")
|
val context = SSLContext.getInstance(protocol)
|
||||||
context.init(null, arrayOf(TrustManager()), null)
|
context.init(null, 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
|
||||||
|
@ -70,6 +70,7 @@ class Request(private val uri: Uri) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "Request"
|
private const val TAG = "Request"
|
||||||
|
const val DEFAULT_TLS_VERSION = "TLSv1.3"
|
||||||
const val DEFAULT_CONNECTION_TIMEOUT_SEC = 10
|
const val DEFAULT_CONNECTION_TIMEOUT_SEC = 10
|
||||||
const val DEFAULT_READ_TIMEOUT_SEC = 10
|
const val DEFAULT_READ_TIMEOUT_SEC = 10
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@ fun confirm(context: Context, @StringRes prompt: Int, onOk: () -> Unit) {
|
||||||
.setTitle(R.string.confirm)
|
.setTitle(R.string.confirm)
|
||||||
.setView(binding.root)
|
.setView(binding.root)
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ -> onOk() }
|
.setPositiveButton(android.R.string.ok) { _, _ -> onOk() }
|
||||||
|
.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() }
|
||||||
.create()
|
.create()
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
|
@ -8,6 +8,6 @@
|
||||||
android:id="@+id/text_view"
|
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"
|
||||||
android:layout_margin="16sp" />
|
style="@style/CometText" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
|
@ -3,12 +3,14 @@
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:padding="16dp"
|
android:padding="16dp"
|
||||||
|
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/identity_name" />
|
android:text="@string/identity_name"
|
||||||
|
style="@style/CometLabel" />
|
||||||
|
|
||||||
<EditText
|
<EditText
|
||||||
android:id="@+id/labelInput"
|
android:id="@+id/labelInput"
|
||||||
|
@ -18,10 +20,33 @@
|
||||||
android:importantForAutofill="no"
|
android:importantForAutofill="no"
|
||||||
android:inputType="text" />
|
android:inputType="text" />
|
||||||
|
|
||||||
|
<Space
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="8dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="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
|
<TextView
|
||||||
android:id="@+id/aliasText"
|
android:id="@+id/aliasText"
|
||||||
|
|
14
app/src/main/res/layout/dialog_input.xml
Normal file
14
app/src/main/res/layout/dialog_input.xml
Normal 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>
|
|
@ -1,12 +1,11 @@
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Reply Preference -->
|
<string-array name="tls_version_entries">
|
||||||
<string-array name="reply_entries">
|
<item>TLS v1.3</item>
|
||||||
<item>Reply</item>
|
<item>TLS v1.2</item>
|
||||||
<item>Reply to all</item>
|
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
<string-array name="reply_values">
|
<string-array name="tls_version_values">
|
||||||
<item>reply</item>
|
<item>TLSv1.3</item>
|
||||||
<item>reply_all</item>
|
<item>TLSv1.2</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
</resources>
|
</resources>
|
|
@ -44,5 +44,8 @@
|
||||||
<string name="confirm">Confirm</string>
|
<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="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="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>
|
</resources>
|
|
@ -14,4 +14,9 @@
|
||||||
<item name="android:textIsSelectable">false</item>
|
<item name="android:textIsSelectable">false</item>
|
||||||
<item name="android:background">?attr/selectableItemBackground</item>
|
<item name="android:background">?attr/selectableItemBackground</item>
|
||||||
</style>
|
</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>
|
</resources>
|
|
@ -13,18 +13,18 @@
|
||||||
app:key="home_set"
|
app:key="home_set"
|
||||||
app:title="@string/pref_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>
|
||||||
|
|
||||||
<PreferenceCategory app:title="@string/pref_protocol_header">
|
<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
|
<SeekBarPreference
|
||||||
android:defaultValue="10"
|
android:defaultValue="10"
|
||||||
android:max="60"
|
android:max="60"
|
||||||
|
|
Reference in a new issue