History: add Room database for history

Also add going back support.
This commit is contained in:
dece 2021-12-12 01:31:35 +01:00
parent 5cb3af8d12
commit 51f822eb9d
4 changed files with 137 additions and 20 deletions

View file

@ -1,6 +1,7 @@
plugins { plugins {
id 'com.android.application' id 'com.android.application'
id 'kotlin-android' id 'kotlin-android'
id 'kotlin-kapt'
} }
android { android {
@ -40,10 +41,12 @@ android {
dependencies { dependencies {
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 'com.google.android.material:material: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.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.3.0"
implementation 'com.google.android.material:material:1.4.0'
kapt "androidx.room:room-compiler:2.3.0"
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

@ -0,0 +1,21 @@
package dev.lowrespalmtree.comet
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [History.HistoryEntry::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun historyEntryDao(): History.HistoryEntryDao
}
object Database {
lateinit var INSTANCE: AppDatabase
fun init(context: Context) {
if (::INSTANCE.isInitialized)
return
INSTANCE = Room.databaseBuilder(context, AppDatabase::class.java, "comet").build()
}
}

View file

@ -0,0 +1,37 @@
package dev.lowrespalmtree.comet
import androidx.room.*
object History {
@Entity
data class HistoryEntry(
@PrimaryKey @ColumnInfo(name = "uri") val uri: String,
@ColumnInfo(name = "title") var title: String?,
@ColumnInfo(name = "lastVisit") var lastVisit: Long,
)
@Dao
interface HistoryEntryDao {
@Query("SELECT * FROM HistoryEntry WHERE :uri = uri LIMIT 1")
fun get(uri: String): HistoryEntry?
@Query("SELECT * FROM HistoryEntry ORDER BY lastVisit DESC")
fun getAll(): List<HistoryEntry>
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(vararg entries: HistoryEntry)
@Update
fun update(vararg entries: HistoryEntry)
}
fun record(uri: String, title: String?) {
val now = System.currentTimeMillis()
val dao = Database.INSTANCE.historyEntryDao()
val entry = dao.get(uri)
if (entry == null)
dao.insert(HistoryEntry(uri, title, now))
else
dao.update(entry.also { it.title = title; it.lastVisit = now })
}
}

View file

@ -1,6 +1,5 @@
package dev.lowrespalmtree.comet package dev.lowrespalmtree.comet
import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
@ -18,9 +17,8 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import dev.lowrespalmtree.comet.databinding.ActivityMainBinding import dev.lowrespalmtree.comet.databinding.ActivityMainBinding
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.Job import java.net.ConnectException
import kotlinx.coroutines.launch
import java.net.UnknownHostException import java.net.UnknownHostException
import java.nio.charset.Charset import java.nio.charset.Charset
@ -29,11 +27,22 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
private lateinit var pageViewModel: PageViewModel private lateinit var pageViewModel: PageViewModel
private lateinit var adapter: ContentAdapter private lateinit var adapter: ContentAdapter
private val currentUrl get() = binding.addressBar.text /** 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>()
/** Are we going back? */
private var goingBack = false
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Database.init(applicationContext)
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
@ -54,16 +63,26 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
} }
} }
binding.contentSwipeLayout.setOnRefreshListener { openUrl(currentUrl.toString()) } binding.contentSwipeLayout.setOnRefreshListener { openUrl(currentUrl) }
pageViewModel.state.observe(this, { updateState(it) }) pageViewModel.state.observe(this, { updateState(it) })
pageViewModel.lines.observe(this, { updateLines(it) }) pageViewModel.lines.observe(this, { updateLines(it) })
pageViewModel.alert.observe(this, { alert(it) }) pageViewModel.event.observe(this, { handleEvent(it) })
}
override fun onBackPressed() {
if (visitedUrls.isNotEmpty()) {
if (visitedUrls.size > 1)
visitedUrls.removeLast()
goingBack = true
openUrl(visitedUrls.removeLast())
} else {
super.onBackPressed()
}
} }
override fun onLinkClick(url: String) { override fun onLinkClick(url: String) {
val base = binding.addressBar.text.toString() openUrl(url, base = if (currentUrl.isNotEmpty()) currentUrl else null)
openUrl(url, base = if (base.isNotEmpty()) base else null)
} }
private fun openUrl(url: String, base: String? = null) { private fun openUrl(url: String, base: String? = null) {
@ -74,7 +93,7 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
when (uri.scheme) { when (uri.scheme) {
"gemini" -> { "gemini" -> {
binding.addressBar.setText(uri.toString()) currentUrl = uri.toString()
pageViewModel.sendGeminiRequest(uri) pageViewModel.sendGeminiRequest(uri)
} }
else -> openUnknownScheme(uri) else -> openUnknownScheme(uri)
@ -102,6 +121,24 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
adapter.setContent(lines) adapter.setContent(lines)
} }
private fun handleEvent(event: PageViewModel.Event) {
Log.d(TAG, "handleEvent: $event")
if (!event.handled) {
when (event) {
is PageViewModel.SuccessEvent -> {
if (goingBack)
goingBack = false
else
visitedUrls.add(event.uri)
}
is PageViewModel.FailureEvent -> {
alert(event.message)
}
}
event.handled = true
}
}
private fun alert(message: String, title: String? = null) { private fun alert(message: String, title: String? = null) {
val builder = AlertDialog.Builder(this) val builder = AlertDialog.Builder(this)
if (title != null) if (title != null)
@ -127,12 +164,16 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
val state: MutableLiveData<State> by lazy { MutableLiveData<State>(State.IDLE) } val state: MutableLiveData<State> by lazy { MutableLiveData<State>(State.IDLE) }
private var linesList = ArrayList<Line>() private var linesList = ArrayList<Line>()
val lines: MutableLiveData<List<Line>> by lazy { MutableLiveData<List<Line>>() } val lines: MutableLiveData<List<Line>> by lazy { MutableLiveData<List<Line>>() }
val alert: MutableLiveData<String> by lazy { MutableLiveData<String>() } val event: MutableLiveData<Event> by lazy { MutableLiveData<Event>() }
enum class State { enum class State {
IDLE, CONNECTING, RECEIVING IDLE, CONNECTING, RECEIVING
} }
abstract class Event(var handled: Boolean = false)
data class SuccessEvent(val uri: String) : Event()
data class FailureEvent(val message: String) : Event()
/** /**
* Perform a request against this URI. * Perform a request against this URI.
* *
@ -148,14 +189,22 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
val socket = request.connect() val socket = request.connect()
val channel = request.proceed(socket, this) val channel = request.proceed(socket, this)
Response.from(channel, viewModelScope) Response.from(channel, viewModelScope)
} catch (e: UnknownHostException) {
signalError("Unknown host \"${uri.authority}\".")
return@launch
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "sendGeminiRequest coroutine: ${e.stackTraceToString()}") Log.e(TAG, "sendGeminiRequest coroutine: ${e.stackTraceToString()}")
signalError("Oops! Whatever we tried to do failed!") // If we got cancelled, die silently.
if (!isActive)
return@launch
signalError(
when (e) {
is UnknownHostException -> "Unknown host \"${uri.authority}\"."
is ConnectException -> "Can't connect to this server: ${e.message}."
else -> "Oops, something failed!"
}
)
return@launch return@launch
} }
if (!isActive)
return@launch
if (response == null) { if (response == null) {
signalError("Can't parse server response.") signalError("Can't parse server response.")
return@launch return@launch
@ -163,25 +212,29 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
Log.i(TAG, "sendRequest: got ${response.code} with meta \"${response.meta}\"") Log.i(TAG, "sendRequest: got ${response.code} with meta \"${response.meta}\"")
when (response.code) { when (response.code) {
Response.Code.SUCCESS -> handleRequestSuccess(response) Response.Code.SUCCESS -> handleRequestSuccess(response, uri)
else -> signalError("Can't handle code ${response.code}.") else -> signalError("Can't handle code ${response.code}.")
} }
} }
} }
private fun signalError(message: String) { private fun signalError(message: String) {
alert.postValue(message) event.postValue(FailureEvent(message))
state.postValue(State.IDLE) state.postValue(State.IDLE)
} }
private suspend fun handleRequestSuccess(response: Response) { private suspend fun handleRequestSuccess(response: Response, uri: Uri) {
state.postValue(State.RECEIVING) state.postValue(State.RECEIVING)
linesList.clear() linesList.clear()
lines.postValue(linesList) lines.postValue(linesList)
val charset = Charset.defaultCharset() val charset = Charset.defaultCharset()
var mainTitle: String? = null
var lastUpdate = System.currentTimeMillis() var lastUpdate = System.currentTimeMillis()
for (line in parseData(response.data, charset, viewModelScope)) { for (line in parseData(response.data, charset, viewModelScope)) {
linesList.add(line) linesList.add(line)
if (mainTitle == null && line is TitleLine && line.level == 1)
mainTitle = line.text
val time = System.currentTimeMillis() val time = System.currentTimeMillis()
if (time - lastUpdate >= 100) { // Throttle to 100ms the recycler view updates… if (time - lastUpdate >= 100) { // Throttle to 100ms the recycler view updates…
lines.postValue(linesList) lines.postValue(linesList)
@ -189,6 +242,9 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
} }
} }
lines.postValue(linesList) lines.postValue(linesList)
History.record(uri.toString(), mainTitle)
event.postValue(SuccessEvent(uri.toString()))
state.postValue(State.IDLE) state.postValue(State.IDLE)
} }
} }