diff --git a/app/build.gradle b/app/build.gradle index 68ef747..bd4cdf3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.application' id 'kotlin-android' + id 'kotlin-kapt' } android { @@ -40,10 +41,12 @@ android { dependencies { implementation 'androidx.core:core-ktx:1.7.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.lifecycle:lifecycle-viewmodel-ktx:2.4.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' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' diff --git a/app/src/main/java/dev/lowrespalmtree/comet/Database.kt b/app/src/main/java/dev/lowrespalmtree/comet/Database.kt new file mode 100644 index 0000000..48ef946 --- /dev/null +++ b/app/src/main/java/dev/lowrespalmtree/comet/Database.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/lowrespalmtree/comet/History.kt b/app/src/main/java/dev/lowrespalmtree/comet/History.kt new file mode 100644 index 0000000..300c900 --- /dev/null +++ b/app/src/main/java/dev/lowrespalmtree/comet/History.kt @@ -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 + + @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 }) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt b/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt index a7c4306..d948683 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt @@ -1,6 +1,5 @@ package dev.lowrespalmtree.comet -import android.annotation.SuppressLint import android.app.Activity import android.content.ActivityNotFoundException import android.content.Intent @@ -18,9 +17,8 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.recyclerview.widget.LinearLayoutManager import dev.lowrespalmtree.comet.databinding.ActivityMainBinding -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch +import kotlinx.coroutines.* +import java.net.ConnectException import java.net.UnknownHostException import java.nio.charset.Charset @@ -29,11 +27,22 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen { private lateinit var pageViewModel: PageViewModel 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() + + /** Are we going back? */ + private var goingBack = false - @SuppressLint("SetTextI18n") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + Database.init(applicationContext) + binding = ActivityMainBinding.inflate(layoutInflater) 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.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) { - val base = binding.addressBar.text.toString() - openUrl(url, base = if (base.isNotEmpty()) base else null) + openUrl(url, base = if (currentUrl.isNotEmpty()) currentUrl else null) } private fun openUrl(url: String, base: String? = null) { @@ -74,7 +93,7 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen { when (uri.scheme) { "gemini" -> { - binding.addressBar.setText(uri.toString()) + currentUrl = uri.toString() pageViewModel.sendGeminiRequest(uri) } else -> openUnknownScheme(uri) @@ -102,6 +121,24 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen { 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) { val builder = AlertDialog.Builder(this) if (title != null) @@ -127,12 +164,16 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen { val state: MutableLiveData by lazy { MutableLiveData(State.IDLE) } private var linesList = ArrayList() val lines: MutableLiveData> by lazy { MutableLiveData>() } - val alert: MutableLiveData by lazy { MutableLiveData() } + val event: MutableLiveData by lazy { MutableLiveData() } enum class State { 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. * @@ -148,14 +189,22 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen { val socket = request.connect() val channel = request.proceed(socket, this) Response.from(channel, viewModelScope) - } catch (e: UnknownHostException) { - signalError("Unknown host \"${uri.authority}\".") - return@launch } catch (e: Exception) { 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 } + if (!isActive) + return@launch if (response == null) { signalError("Can't parse server response.") return@launch @@ -163,25 +212,29 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen { Log.i(TAG, "sendRequest: got ${response.code} with meta \"${response.meta}\"") when (response.code) { - Response.Code.SUCCESS -> handleRequestSuccess(response) + Response.Code.SUCCESS -> handleRequestSuccess(response, uri) else -> signalError("Can't handle code ${response.code}.") } } } private fun signalError(message: String) { - alert.postValue(message) + event.postValue(FailureEvent(message)) state.postValue(State.IDLE) } - private suspend fun handleRequestSuccess(response: Response) { + private suspend fun handleRequestSuccess(response: Response, uri: Uri) { state.postValue(State.RECEIVING) + linesList.clear() lines.postValue(linesList) val charset = Charset.defaultCharset() + var mainTitle: String? = null var lastUpdate = System.currentTimeMillis() for (line in parseData(response.data, charset, viewModelScope)) { linesList.add(line) + if (mainTitle == null && line is TitleLine && line.level == 1) + mainTitle = line.text val time = System.currentTimeMillis() if (time - lastUpdate >= 100) { // Throttle to 100ms the recycler view updates… lines.postValue(linesList) @@ -189,6 +242,9 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen { } } lines.postValue(linesList) + + History.record(uri.toString(), mainTitle) + event.postValue(SuccessEvent(uri.toString())) state.postValue(State.IDLE) } }