Compare commits

..

7 commits

23 changed files with 552 additions and 294 deletions

View file

@ -39,14 +39,21 @@ android {
} }
dependencies { dependencies {
def nav_version = "2.3.5"
def room_version = "2.4.0"
implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.0' implementation 'androidx.appcompat:appcompat:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.2' implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0" implementation 'androidx.fragment:fragment-ktx:1.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation "androidx.room:room-runtime:2.4.0" implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation "androidx.room:room-runtime:$room_version"
implementation "androidx.room:room-ktx:$room_version"
implementation 'com.google.android.material:material:1.4.0' implementation 'com.google.android.material:material:1.4.0'
kapt "androidx.room:room-compiler:2.4.0" kapt "androidx.room:room-compiler:$room_version"
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'

View file

@ -19,7 +19,7 @@ import dev.lowrespalmtree.comet.databinding.*
* it could be a n-to-1 relation: many lines belong to the same block. This of course changes a bit * it could be a n-to-1 relation: many lines belong to the same block. This of course changes a bit
* the way we have to render things. * the way we have to render things.
*/ */
class ContentAdapter(private val listener: ContentAdapterListen) : class ContentAdapter(private val listener: ContentAdapterListener) :
RecyclerView.Adapter<ContentAdapter.ContentViewHolder>() { RecyclerView.Adapter<ContentAdapter.ContentViewHolder>() {
private var lines = listOf<Line>() private var lines = listOf<Line>()
@ -27,7 +27,7 @@ class ContentAdapter(private val listener: ContentAdapterListen) :
private var blocks = mutableListOf<ContentBlock>() private var blocks = mutableListOf<ContentBlock>()
private var lastBlockCount = 0 private var lastBlockCount = 0
interface ContentAdapterListen { interface ContentAdapterListener {
fun onLinkClick(url: String) fun onLinkClick(url: String)
} }
@ -161,15 +161,15 @@ class ContentAdapter(private val listener: ContentAdapterListen) :
override fun getItemCount(): Int = blocks.size override fun getItemCount(): Int = blocks.size
companion object { companion object {
const val TAG = "ContentRecycler" private const val TAG = "ContentRecycler"
const val TYPE_EMPTY = 0 private const val TYPE_EMPTY = 0
const val TYPE_TITLE_1 = 1 private const val TYPE_TITLE_1 = 1
const val TYPE_TITLE_2 = 2 private const val TYPE_TITLE_2 = 2
const val TYPE_TITLE_3 = 3 private const val TYPE_TITLE_3 = 3
const val TYPE_PARAGRAPH = 4 private const val TYPE_PARAGRAPH = 4
const val TYPE_LINK = 5 private const val TYPE_LINK = 5
const val TYPE_PREFORMATTED = 6 private const val TYPE_PREFORMATTED = 6
const val TYPE_BLOCKQUOTE = 7 private const val TYPE_BLOCKQUOTE = 7
const val TYPE_LIST_ITEM = 8 private const val TYPE_LIST_ITEM = 8
} }
} }

View file

@ -19,7 +19,7 @@ class PreTextLine(val text: String) : Line
class BlockquoteLine(val text: String) : Line class BlockquoteLine(val text: String) : Line
class ListItemLine(val text: String) : Line class ListItemLine(val text: String) : Line
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(

View file

@ -13,19 +13,22 @@ object History {
@Dao @Dao
interface HistoryEntryDao { interface HistoryEntryDao {
@Query("SELECT * FROM HistoryEntry WHERE :uri = uri LIMIT 1") @Query("SELECT * FROM HistoryEntry WHERE :uri = uri LIMIT 1")
fun get(uri: String): HistoryEntry? suspend fun get(uri: String): HistoryEntry?
@Query("SELECT * FROM HistoryEntry ORDER BY lastVisit DESC") @Query("SELECT * FROM HistoryEntry ORDER BY lastVisit DESC")
fun getAll(): List<HistoryEntry> suspend fun getAll(): List<HistoryEntry>
@Query("SELECT * FROM HistoryEntry ORDER BY lastVisit DESC LIMIT 1")
suspend fun getLast(): HistoryEntry?
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(vararg entries: HistoryEntry) suspend fun insert(vararg entries: HistoryEntry)
@Update @Update
fun update(vararg entries: HistoryEntry) suspend fun update(vararg entries: HistoryEntry)
} }
fun record(uri: String, title: String?) { suspend fun record(uri: String, title: String?) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val dao = Database.INSTANCE.historyEntryDao() val dao = Database.INSTANCE.historyEntryDao()
val entry = dao.get(uri) val entry = dao.get(uri)
@ -34,4 +37,6 @@ object History {
else else
dao.update(entry.also { it.title = title; it.lastVisit = now }) dao.update(entry.also { it.title = title; it.lastVisit = now })
} }
suspend fun getLast(): HistoryEntry? = Database.INSTANCE.historyEntryDao().getLast()
} }

View file

@ -1,201 +1,23 @@
package dev.lowrespalmtree.comet package dev.lowrespalmtree.comet
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.Intent.ACTION_VIEW
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.text.InputType
import android.util.Log
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.FrameLayout.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout.LayoutParams.WRAP_CONTENT
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.setMargins import androidx.navigation.fragment.NavHostFragment
import androidx.lifecycle.ViewModelProvider import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.navigation.NavigationView
import dev.lowrespalmtree.comet.databinding.ActivityMainBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen { class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var pageViewModel: PageViewModel
private lateinit var adapter: ContentAdapter
/** Property to access and set the current address bar URL value. */
private var currentUrl
get() = binding.addressBar.text.toString()
set(value) = binding.addressBar.setText(value)
/** A non-saved list of visited URLs. Not an history, just used for going back. */
private val visitedUrls = mutableListOf<String>()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
Database.init(applicationContext) // TODO move to App Startup?
Database.init(applicationContext) supportFragmentManager.findFragmentById(R.id.nav_host_fragment)?.also { navHost ->
findViewById<NavigationView>(R.id.drawer_navigation)?.apply {
binding = ActivityMainBinding.inflate(layoutInflater) setupWithNavController((navHost as NavHostFragment).navController)
setContentView(binding.root)
pageViewModel = ViewModelProvider(this)[PageViewModel::class.java]
adapter = ContentAdapter(this)
binding.contentRecycler.layoutManager = LinearLayoutManager(this)
binding.contentRecycler.adapter = adapter
binding.addressBar.setOnEditorActionListener { view, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
openUrl(view.text.toString())
val imm = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
view.clearFocus()
true
} else {
false
}
}
binding.contentSwipeLayout.setOnRefreshListener { openUrl(currentUrl) }
pageViewModel.state.observe(this, { updateState(it) })
pageViewModel.lines.observe(this, { updateLines(it) })
pageViewModel.event.observe(this, { handleEvent(it) })
}
override fun onBackPressed() {
visitedUrls.removeLastOrNull() // Always remove current page first.
val previousUrl = visitedUrls.removeLastOrNull()
if (previousUrl != null)
openUrl(previousUrl)
else
super.onBackPressed()
}
override fun onLinkClick(url: String) {
openUrl(url, base = if (currentUrl.isNotEmpty()) currentUrl else null)
}
/**
* Open an URL.
*
* This function can be called after the user entered an URL in the app bar, clicked on a link,
* whatever. To make the user's life a bit easier, this function also makes a few guesses:
* - If the URL is not absolute, make it so from a base URL (e.g. the current URL) or assume
* the user only typed a hostname without scheme and use a utility function to make it
* absolute.
* - If it's an absolute Gemini URL with an empty path, use "/" instead as per the spec.
*/
private fun openUrl(url: String, base: String? = null, redirections: Int = 0) {
if (redirections >= 5) {
alert("Too many redirections.")
return
}
var uri = Uri.parse(url)
if (!uri.isAbsolute) {
uri = if (!base.isNullOrEmpty()) joinUrls(base, url) else toGeminiUri(uri)
} else if (uri.scheme == "gemini" && uri.path.isNullOrEmpty()) {
uri = uri.buildUpon().path("/").build()
}
when (uri.scheme) {
"gemini" -> pageViewModel.sendGeminiRequest(uri)
else -> openUnknownScheme(uri)
}
}
private fun updateState(state: PageViewModel.State) {
Log.d(TAG, "updateState: $state")
when (state) {
PageViewModel.State.IDLE -> {
binding.contentProgressBar.hide()
binding.contentSwipeLayout.isRefreshing = false
}
PageViewModel.State.CONNECTING -> {
binding.contentProgressBar.show()
}
PageViewModel.State.RECEIVING -> {
binding.appBar.setExpanded(true, true)
binding.contentSwipeLayout.isRefreshing = false
} }
} }
} }
private fun updateLines(lines: List<Line>) {
Log.d(TAG, "updateLines: ${lines.size} lines")
adapter.setLines(lines)
}
private fun handleEvent(event: PageViewModel.Event) {
Log.d(TAG, "handleEvent: $event")
if (!event.handled) {
when (event) {
is PageViewModel.InputEvent -> {
val editText = EditText(this).apply { inputType = InputType.TYPE_CLASS_TEXT }
val inputView = FrameLayout(this).apply {
addView(FrameLayout(this@MainActivity).apply {
addView(editText)
val params = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
params.setMargins(resources.getDimensionPixelSize(R.dimen.text_margin))
layoutParams = params
})
}
AlertDialog.Builder(this)
.setMessage(if (event.prompt.isNotEmpty()) event.prompt else "Input required")
.setView(inputView)
.setPositiveButton(android.R.string.ok) { _, _ ->
val newUri = event.uri.buildUpon().query(editText.text.toString()).build()
openUrl(newUri.toString(), base = currentUrl)
}
.setOnDismissListener { updateState(PageViewModel.State.IDLE) }
.create()
.show()
}
is PageViewModel.SuccessEvent -> {
currentUrl = event.uri
visitedUrls.add(event.uri)
}
is PageViewModel.RedirectEvent -> {
openUrl(event.uri, base = currentUrl, redirections = event.redirects)
}
is PageViewModel.FailureEvent -> {
var message = event.details
if (!event.serverDetails.isNullOrEmpty())
message += "\n\nServer details: ${event.serverDetails}"
if (!isConnectedToNetwork(this))
message += "\n\nInternet may be inaccessible…"
alert(message, title = event.short)
updateState(PageViewModel.State.IDLE)
}
}
event.handled = true
}
}
private fun alert(message: String, title: String? = null) {
AlertDialog.Builder(this)
.setTitle(title ?: getString(R.string.error_alert_title))
.setMessage(message)
.create()
.show()
}
private fun openUnknownScheme(uri: Uri) {
try {
startActivity(Intent(ACTION_VIEW, uri))
} catch (e: ActivityNotFoundException) {
alert("Can't find an app to open \"${uri.scheme}\" URLs.")
}
}
companion object {
const val TAG = "MainActivity"
}
} }

View file

@ -0,0 +1,225 @@
package dev.lowrespalmtree.comet
import android.app.Activity
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.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import dev.lowrespalmtree.comet.databinding.FragmentPageViewBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ExperimentalCoroutinesApi
class PageViewFragment : Fragment(), ContentAdapter.ContentAdapterListener {
private lateinit var binding: FragmentPageViewBinding
private lateinit var pageViewModel: PageViewModel
private lateinit var adapter: ContentAdapter
/** Property to access and set the current address bar URL value. */
private var currentUrl
get() = binding.addressBar.text.toString()
set(value) = binding.addressBar.setText(value)
/** A non-saved list of visited URLs. Not an history, just used for going back. */
private val visitedUrls = mutableListOf<String>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentPageViewBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
pageViewModel = ViewModelProvider(this)[PageViewModel::class.java]
adapter = ContentAdapter(this)
binding.contentRecycler.layoutManager = LinearLayoutManager(requireContext())
binding.contentRecycler.adapter = adapter
binding.addressBar.setOnEditorActionListener { v, id, _ -> onAddressBarAction(v, id) }
binding.contentSwipeLayout.setOnRefreshListener { openUrl(currentUrl) }
pageViewModel.state.observe(viewLifecycleOwner, { updateState(it) })
pageViewModel.lines.observe(viewLifecycleOwner, { updateLines(it) })
pageViewModel.event.observe(viewLifecycleOwner, { handleEvent(it) })
activity?.onBackPressedDispatcher?.addCallback(viewLifecycleOwner) { onBackPressed() }
if (visitedUrls.isEmpty()) {
PreferenceManager.getDefaultSharedPreferences(requireContext())
.getString("home", null)?.let { openUrl(it) }
}
}
override fun onLinkClick(url: String) {
openUrl(url, base = if (currentUrl.isNotEmpty()) currentUrl else null)
}
private fun onBackPressed() {
if (visitedUrls.size >= 2) {
visitedUrls.removeLastOrNull() // Always remove current page first.
openUrl(visitedUrls.removeLastOrNull()!!)
}
}
private fun onAddressBarAction(addressBar: TextView, actionId: Int): Boolean {
if (actionId == EditorInfo.IME_ACTION_DONE) {
openUrl(addressBar.text.toString())
activity?.run {
val imm = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(addressBar.windowToken, 0)
}
addressBar.clearFocus()
return true
}
return false
}
/**
* Open an URL.
*
* This function can be called after the user entered an URL in the app bar, clicked on a link,
* whatever. To make the user's life a bit easier, this function also makes a few guesses:
* - If the URL is not absolute, make it so from a base URL (e.g. the current URL) or assume
* the user only typed a hostname without scheme and use a utility function to make it
* absolute.
* - If it's an absolute Gemini URL with an empty path, use "/" instead as per the spec.
*/
private fun openUrl(url: String, base: String? = null, redirections: Int = 0) {
if (redirections >= 5) {
alert("Too many redirections.")
return
}
var uri = Uri.parse(url)
if (!uri.isAbsolute) {
uri = if (!base.isNullOrEmpty()) joinUrls(base, url) else toGeminiUri(uri)
} else if (uri.scheme == "gemini" && uri.path.isNullOrEmpty()) {
uri = uri.buildUpon().path("/").build()
}
when (uri.scheme) {
"gemini" -> {
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
val connectionTimeout =
prefs.getInt("connection_timeout", Request.DEFAULT_CONNECTION_TIMEOUT_SEC)
val readTimeout =
prefs.getInt("read_timeout", Request.DEFAULT_READ_TIMEOUT_SEC)
pageViewModel.sendGeminiRequest(uri, connectionTimeout, readTimeout)
}
else -> openUnknownScheme(uri)
}
}
private fun updateState(state: PageViewModel.State) {
Log.d(TAG, "updateState: $state")
when (state) {
PageViewModel.State.IDLE -> {
binding.contentProgressBar.hide()
binding.contentSwipeLayout.isRefreshing = false
}
PageViewModel.State.CONNECTING -> {
binding.contentProgressBar.show()
}
PageViewModel.State.RECEIVING -> {
binding.appBarLayout.setExpanded(true, true)
binding.contentSwipeLayout.isRefreshing = false
}
}
}
private fun updateLines(lines: List<Line>) {
Log.d(TAG, "updateLines: ${lines.size} lines")
adapter.setLines(lines)
}
private fun handleEvent(event: PageViewModel.Event) {
Log.d(TAG, "handleEvent: $event")
if (!event.handled) {
when (event) {
is PageViewModel.InputEvent -> {
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(if (event.prompt.isNotEmpty()) event.prompt else "Input required")
.setView(inputView)
.setPositiveButton(android.R.string.ok) { _, _ ->
val newUri =
event.uri.buildUpon().query(editText.text.toString()).build()
openUrl(newUri.toString(), base = currentUrl)
}
.setOnDismissListener { updateState(PageViewModel.State.IDLE) }
.create()
.show()
}
is PageViewModel.SuccessEvent -> {
currentUrl = event.uri
visitedUrls.add(event.uri)
}
is PageViewModel.RedirectEvent -> {
openUrl(event.uri, base = currentUrl, redirections = event.redirects)
}
is PageViewModel.FailureEvent -> {
var message = event.details
if (!event.serverDetails.isNullOrEmpty())
message += "\n\nServer details: ${event.serverDetails}"
if (!isConnectedToNetwork(requireContext()))
message += "\n\nInternet may be inaccessible…"
alert(message, title = event.short)
updateState(PageViewModel.State.IDLE)
}
}
event.handled = true
}
}
private fun alert(message: String, title: String? = null) {
AlertDialog.Builder(requireContext())
.setTitle(title ?: getString(R.string.error_alert_title))
.setMessage(message)
.create()
.show()
}
private fun openUnknownScheme(uri: Uri) {
try {
startActivity(Intent(Intent.ACTION_VIEW, uri))
} catch (e: ActivityNotFoundException) {
alert("Can't find an app to open \"${uri.scheme}\" URLs.")
}
}
companion object {
private const val TAG = "PageViewFragment"
}
}

View file

@ -39,14 +39,14 @@ class PageViewModel : ViewModel() {
* *
* The URI must be valid, absolute and with a gemini scheme. * The URI must be valid, absolute and with a gemini scheme.
*/ */
fun sendGeminiRequest(uri: Uri, redirects: Int = 0) { fun sendGeminiRequest(uri: Uri, connectionTimeout: Int, readTimeout: Int, redirects: Int = 0) {
Log.d(TAG, "sendRequest: URI \"$uri\"") Log.d(TAG, "sendRequest: URI \"$uri\"")
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) {
val response = try { val response = try {
val request = Request(uri) val request = Request(uri)
val socket = request.connect() val socket = request.connect(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) {
@ -184,6 +184,6 @@ class PageViewModel : ViewModel() {
} }
companion object { companion object {
const val TAG = "PageViewModel" private const val TAG = "PageViewModel"
} }
} }

View file

@ -17,13 +17,13 @@ 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(): SSLSocket { fun connect(connectionTimeout: Int, readTimeout: Int): SSLSocket {
Log.d(TAG, "connect") Log.d(TAG, "connect")
val context = SSLContext.getInstance("TLSv1.2") val context = SSLContext.getInstance("TLSv1.2")
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 = 10000 socket.soTimeout = readTimeout * 1000
socket.connect(InetSocketAddress(uri.host, port), 10000) socket.connect(InetSocketAddress(uri.host, port), connectionTimeout * 1000)
socket.startHandshake() socket.startHandshake()
return socket return socket
} }
@ -69,6 +69,8 @@ class Request(private val uri: Uri) {
} }
companion object { companion object {
const val TAG = "Request" private const val TAG = "Request"
const val DEFAULT_CONNECTION_TIMEOUT_SEC = 10
const val DEFAULT_READ_TIMEOUT_SEC = 10
} }
} }

View file

@ -0,0 +1,27 @@
package dev.lowrespalmtree.comet
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import androidx.preference.EditTextPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class SettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.root_preferences, rootKey)
findPreference<Preference>("home_set")?.setOnPreferenceClickListener {
lifecycleScope.launch(Dispatchers.IO) {
val lastEntry = History.getLast()
launch(Dispatchers.Main) {
if (lastEntry != null)
findPreference<EditTextPreference>("home")?.text = lastEntry.uri
else
toast(requireContext(), R.string.no_current_url)
}
}
true
}
}
}

View file

@ -0,0 +1,7 @@
package dev.lowrespalmtree.comet
import android.content.Context
import android.widget.Toast
fun toast(context: Context, stringId: Int, length: Int = Toast.LENGTH_SHORT) =
Toast.makeText(context, stringId, length).show()

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="-50%p" android:toXDelta="0"
android:duration="@android:integer/config_mediumAnimTime"/>
<alpha android:fromAlpha="0.0" android:toAlpha="1.0"
android:duration="@android:integer/config_mediumAnimTime" />
</set>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="50%p" android:toXDelta="0"
android:duration="@android:integer/config_mediumAnimTime"/>
<alpha android:fromAlpha="0.0" android:toAlpha="1.0"
android:duration="@android:integer/config_mediumAnimTime" />
</set>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="0" android:toXDelta="-50%p"
android:duration="@android:integer/config_mediumAnimTime"/>
<alpha android:fromAlpha="1.0" android:toAlpha="0.0"
android:duration="@android:integer/config_mediumAnimTime" />
</set>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="0" android:toXDelta="50%p"
android:duration="@android:integer/config_mediumAnimTime"/>
<alpha android:fromAlpha="1.0" android:toAlpha="0.0"
android:duration="@android:integer/config_mediumAnimTime" />
</set>

View file

@ -1,71 +1,25 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.drawerlayout.widget.DrawerLayout
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root" android:layout_height="match_parent"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout <androidx.fragment.app.FragmentContainerView
android:id="@+id/app_bar" android:id="@+id/nav_host_fragment"
android:layout_width="match_parent" android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_height="56dp">
<Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
app:contentScrim="?attr/colorPrimary" app:navGraph="@navigation/main"
app:layout_scrollFlags="scroll|enterAlways" app:defaultNavHost="true" />
app:toolbarId="@+id/toolbar">
<EditText <com.google.android.material.navigation.NavigationView
android:id="@+id/address_bar" android:id="@+id/drawer_navigation"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_margin="4dp" android:layout_gravity="start"
android:hint="@string/url" android:fitsSystemWindows="true"
android:imeOptions="actionDone|actionGo" app:menu="@menu/drawer" />
android:importantForAutofill="no"
android:inputType="textUri"
android:text=""
tools:ignore="TextContrastCheck" />
</Toolbar> </androidx.drawerlayout.widget.DrawerLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/content_swipe_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<RelativeLayout
android:id="@+id/content_inner_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/content_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent" />
<androidx.core.widget.ContentLoadingProgressBar
android:id="@+id/content_progress_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="16dp"
android:layout_alignParentTop="true"
android:layout_marginTop="-8dp"
android:indeterminate="true"
android:visibility="gone" />
</RelativeLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_layout"
android:layout_width="match_parent"
android:layout_height="56dp">
<Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:contentScrim="?attr/colorPrimary"
app:layout_scrollFlags="scroll|enterAlways"
app:toolbarId="@+id/toolbar">
<EditText
android:id="@+id/address_bar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="4dp"
android:hint="@string/url"
android:imeOptions="actionDone|actionGo"
android:importantForAutofill="no"
android:inputType="textUri"
android:text=""
tools:ignore="TextContrastCheck" />
</Toolbar>
</com.google.android.material.appbar.AppBarLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/content_swipe_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<RelativeLayout
android:id="@+id/content_inner_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/content_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent" />
<androidx.core.widget.ContentLoadingProgressBar
android:id="@+id/content_progress_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="16dp"
android:layout_alignParentTop="true"
android:layout_marginTop="-8dp"
android:indeterminate="true"
android:visibility="gone" />
</RelativeLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/history"
android:icon="@android:drawable/ic_menu_recent_history"
android:title="@string/history" />
<item
android:id="@+id/settingsFragment"
android:icon="@android:drawable/ic_menu_preferences"
android:title="@string/settings" />
</menu>

View file

@ -1,10 +0,0 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context="dev.lowrespalmtree.comet.ScrollingActivity">
<item
android:id="@+id/action_settings"
android:orderInCategory="100"
android:title="@string/action_settings"
app:showAsAction="never" />
</menu>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/main"
app:startDestination="@id/pageViewFragment">
<fragment
android:id="@+id/pageViewFragment"
android:name="dev.lowrespalmtree.comet.PageViewFragment"
android:label="PageViewFragment" />
<fragment
android:id="@+id/settingsFragment"
android:name="dev.lowrespalmtree.comet.SettingsFragment"
android:label="SettingsFragment" />
</navigation>

View file

@ -0,0 +1,12 @@
<resources>
<!-- Reply Preference -->
<string-array name="reply_entries">
<item>Reply</item>
<item>Reply to all</item>
</string-array>
<string-array name="reply_values">
<item>reply</item>
<item>reply_all</item>
</string-array>
</resources>

View file

@ -1,6 +1,32 @@
<resources> <resources>
<string name="app_name">Comet</string> <string name="app_name">Comet</string>
<string name="error_alert_title">Error</string> <string name="error_alert_title">Error</string>
<string name="action_settings">Settings</string>
<string name="url">URL</string> <string name="url">URL</string>
<string name="settings">Settings</string>
<string name="history">History</string>
<!-- Preferences General -->
<string name="pref_general_header">General</string>
<string name="pref_home_title">Home page</string>
<string name="pref_home_set">Set last visited page as home page</string>
<string name="no_current_url">No current URL.</string>
<!-- Preference Protocol -->
<string name="pref_protocol_header">Protocol</string>
<string name="pref_connection_timeout_title">Connection timeout (seconds)</string>
<string name="pref_read_timeout_title">Read timeout (seconds)</string>
<!-- Newly added -->
<string name="signature_title">Your signature</string>
<string name="reply_title">Default reply action</string>
<string name="sync_title">Sync email periodically</string>
<string name="attachment_title">Download incoming attachments</string>
<string name="attachment_summary_on">Automatically download attachments for incoming emails
</string>
<string name="attachment_summary_off">Only download attachments when manually requested</string>
</resources> </resources>

View file

@ -0,0 +1,56 @@
<androidx.preference.PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory app:title="@string/pref_general_header">
<EditTextPreference
app:key="home"
app:title="@string/pref_home_title"
app:useSimpleSummaryProvider="true" />
<Preference
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">
<SeekBarPreference
app:key="connection_timeout"
app:title="@string/pref_connection_timeout_title"
app:seekBarIncrement="1"
app:showSeekBarValue="true"
android:max="60"
android:defaultValue="10" />
<SeekBarPreference
app:key="read_timeout"
app:title="@string/pref_read_timeout_title"
app:seekBarIncrement="1"
app:showSeekBarValue="true"
android:max="60"
android:defaultValue="10" />
<SwitchPreferenceCompat
app:key="sync"
app:title="@string/sync_title" />
<SwitchPreferenceCompat
app:dependency="sync"
app:key="attachment"
app:summaryOff="@string/attachment_summary_off"
app:summaryOn="@string/attachment_summary_on"
app:title="@string/attachment_title" />
</PreferenceCategory>
</androidx.preference.PreferenceScreen>

View file

@ -1,4 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
repositories { repositories {
google() google()
@ -6,10 +7,8 @@ buildscript {
} }
dependencies { dependencies {
classpath "com.android.tools.build:gradle:7.0.4" classpath "com.android.tools.build:gradle:7.0.4"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.0" classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10'
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
} }
} }