ariane/app/src/main/java/oppen/ariane/ui/GemActivity.kt

412 lines
16 KiB
Kotlin

package oppen.ariane.ui
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.Intent
import android.media.MediaPlayer
import android.net.Uri
import android.os.Bundle
import android.view.inputmethod.EditorInfo
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import oppen.ariane.Ariane
import oppen.ariane.R
import oppen.ariane.databinding.ActivityGemBinding
import oppen.ariane.io.GemState
import oppen.ariane.io.database.ArianeDatabase
import oppen.ariane.io.database.bookmarks.BookmarksDatasource
import oppen.ariane.io.gemini.Datasource
import oppen.ariane.io.gemini.GeminiResponse
import oppen.ariane.io.gemini.RuntimeCache
import oppen.ariane.ui.audio_player.AudioPlayer
import oppen.ariane.ui.bookmarks.BookmarkDialog
import oppen.ariane.ui.bookmarks.BookmarksDialog
import oppen.ariane.ui.content_image.ImageDialog
import oppen.ariane.ui.content_text.TextDialog
import oppen.ariane.ui.modals_menus.LinkPopup
import oppen.ariane.ui.modals_menus.about.AboutDialog
import oppen.ariane.ui.modals_menus.history.HistoryDialog
import oppen.ariane.ui.modals_menus.input.InputDialog
import oppen.ariane.ui.modals_menus.overflow.OverflowPopup
import oppen.ariane.ui.settings.SettingsActivity
import oppen.hideKeyboard
import oppen.visibleRetainingSpace
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.net.URLEncoder
const val CREATE_IMAGE_FILE_REQ = 628
const val CREATE_AUDIO_FILE_REQ = 629
const val CREATE_BINARY_FILE_REQ = 630
class GemActivity : AppCompatActivity() {
private var inSearch = false
private val mediaPlayer = MediaPlayer()
private lateinit var bookmarkDatasource: BookmarksDatasource
private val model by viewModels<GemViewModel>()
private lateinit var binding: ActivityGemBinding
private val adapter = GemtextAdapter { adapter, uri, longTap, position: Int, view ->
if(longTap){
LinkPopup.show(view, uri){ menuId ->
when (menuId) {
R.id.link_menu_load_image -> {
loadingView(true)
model.requestInlineImage(uri){ imageUri ->
imageUri?.let{
runOnUiThread {
loadingView(false)
adapter.loadImage(position, imageUri)
}
}
}
}
R.id.link_menu_copy -> {
Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, uri.toString())
type = "text/plain"
startActivity(Intent.createChooser(this, null))
}
}
}
}
}else{
//Reset input text hint after user has been searching
if(inSearch) {
binding.addressEdit.hint = getString(R.string.main_input_hint)
inSearch = false
}
model.request(uri)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val db = ArianeDatabase(applicationContext)
bookmarkDatasource = db.bookmarks()
binding = DataBindingUtil.setContentView(this, R.layout.activity_gem)
binding.viewmodel = model
binding.lifecycleOwner = this
binding.gemtextRecycler.layoutManager = LinearLayoutManager(this)
binding.gemtextRecycler.adapter = adapter
model.initialise(
home = PreferenceManager.getDefaultSharedPreferences(this).getString("home_capsule", Ariane.DEFAULT_HOME_CAPSULE) ?: Ariane.DEFAULT_HOME_CAPSULE,
gemini = Datasource.factory(this, db.history()),
db = db,
onState = this::handleState)
binding.addressEdit.setOnEditorActionListener { _, actionId, _ ->
when (actionId) {
EditorInfo.IME_ACTION_GO -> {
val input = binding.addressEdit.text.toString()
when {
input.startsWith("gemini://") -> model.request(input)
else -> model.request("${Ariane.GEMINI_USER_SEARCH_BASE}${URLEncoder.encode(input, "UTF-8")}")
}
binding.addressEdit.hideKeyboard()
return@setOnEditorActionListener true
}
else -> return@setOnEditorActionListener false
}
}
binding.more.setOnClickListener {
OverflowPopup.show(binding.more){ menuId ->
when (menuId) {
R.id.overflow_menu_search -> {
binding.addressEdit.hint = getString(R.string.main_input_search_hint)
binding.addressEdit.text?.clear()
binding.addressEdit.requestFocus()
inSearch = true
}
R.id.overflow_menu_bookmark -> {
println("Bookmark: ---------------------------")
val name = adapter.inferTitle()
BookmarkDialog(
this,
BookmarkDialog.mode_new,
bookmarkDatasource,
binding.addressEdit.text.toString(),
name ?: ""
) { _, _ ->
bookmarkDatasource.get { bookmarks ->
bookmarks.forEach { bookmark ->
println("Bookmark: ${bookmark.label}: ${bookmark.uri}")
}
}
}.show()
}
R.id.overflow_menu_bookmarks -> {
println("Bookmarks: --------------------------")
BookmarksDialog(this, bookmarkDatasource) { bookmark ->
model.request(bookmark.uri)
}.show()
}
R.id.overflow_menu_share -> {
Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, binding.addressEdit.text.toString())
type = "text/plain"
startActivity(Intent.createChooser(this, null))
}
}
R.id.overflow_menu_history -> HistoryDialog.show(this, db.history()) { historyAddress ->
model.request(historyAddress)
}
R.id.overflow_menu_about -> AboutDialog.show(this)
R.id.overflow_menu_settings -> {
startActivity(Intent(this, SettingsActivity::class.java))
}
}
}
}
binding.home.setOnClickListener {
val home = PreferenceManager.getDefaultSharedPreferences(this).getString("home_capsule", Ariane.DEFAULT_HOME_CAPSULE)
model.request(home!!)
}
binding.pullToRefresh.setOnRefreshListener {
val address = binding.addressEdit.text.toString()
RuntimeCache.remove(address)
model.request(address)
}
checkIntentExtras(intent)
}
private fun handleState(state: GemState) {
binding.pullToRefresh.isRefreshing = false
when (state) {
is GemState.AppQuery -> runOnUiThread { showAlert("App backdoor/query not implemented yet") }
is GemState.ResponseInput -> runOnUiThread {
loadingView(false)
InputDialog.show(this, state) { queryAddress ->
model.request(queryAddress)
}
}
is GemState.Requesting -> loadingView(true)
is GemState.NotGeminiRequest -> externalProtocol(state)
is GemState.ResponseError -> showAlert("${GeminiResponse.getCodeString(state.header.code)}: ${state.header.meta}")
is GemState.ResponseGemtext -> renderGemtext(state)
is GemState.ResponseText -> renderText(state)
is GemState.ResponseImage -> renderImage(state)
is GemState.ResponseAudio -> renderAudio(state)
is GemState.ResponseBinary -> renderBinary(state)
is GemState.Blank -> {
binding.addressEdit.setText("")
adapter.render(arrayListOf())
}
is GemState.ResponseUnknownMime -> {
runOnUiThread {
loadingView(false)
AlertDialog.Builder(this, R.style.AppDialogTheme)
.setTitle(R.string.unknown_mime_dialog_title)
.setMessage("Address: ${state.uri}\nMeta: ${state.header.meta}")
.setPositiveButton("Download") { _, _ ->
loadingView(true)
model.requestBinaryDownload(state.uri)
}
.setNegativeButton("Cancel") { _, _ -> }
.show()
}
}
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
intent?.let{
checkIntentExtras(intent)
}
}
/**
*
* Checks intent to see if Activity was opened to handle selected text
*
*/
private fun checkIntentExtras(intent: Intent) {
//Via ProcessTextActivity from selected text in another app
if(intent.hasExtra("process_text")){
val processText = intent.getStringExtra("process_text")
binding.addressEdit.setText(processText)
model.request(processText ?: "")
return
}
//From clicking a gemini:// address
val uri = intent.data
if(uri != null){
binding.addressEdit.setText(uri.toString())
model.request(uri.toString())
return
}
}
private fun showAlert(message: String) = runOnUiThread{
loadingView(false)
if(message.length > 40){
AlertDialog.Builder(this)
.setMessage(message)
.show()
}else {
Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show()
}
}
private fun externalProtocol(state: GemState.NotGeminiRequest) = runOnUiThread {
loadingView(false)
val browserIntent = Intent(Intent.ACTION_VIEW)
browserIntent.data = Uri.parse(state.uri.toString())
try {
startActivity(browserIntent)
}catch (e: ActivityNotFoundException){
showAlert("No app installed that can open ${state.uri}")
}
}
private fun renderGemtext(state: GemState.ResponseGemtext) = runOnUiThread {
loadingView(false)
//todo - colours didn't change when switching themes, so disabled for now
//val addressSpan = SpannableString(state.uri.toString())
//addressSpan.set(0, 9, ForegroundColorSpan(resources.getColor(R.color.protocol_address)))
binding.addressEdit.setText(state.uri.toString())
adapter.render(state.lines)
//Scroll to top
binding.gemtextRecycler.post {
binding.gemtextRecycler.scrollToPosition(0)
}
}
private fun renderText(state: GemState.ResponseText) = runOnUiThread {
loadingView(false)
TextDialog.show(this, state)
}
var imageState: GemState.ResponseImage? = null
var audioState: GemState.ResponseAudio? = null
var binaryState: GemState.ResponseBinary? = null
private fun renderAudio(state: GemState.ResponseAudio) = runOnUiThread {
loadingView(false)
AudioPlayer.play(this, binding, mediaPlayer, state){ state ->
audioState = state
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "audio/mpeg"
intent.putExtra(Intent.EXTRA_TITLE, File(state.uri.path).name)
startActivityForResult(intent, CREATE_AUDIO_FILE_REQ)
}
}
private fun renderImage(state: GemState.ResponseImage) = runOnUiThread{
loadingView(false)
ImageDialog.show(this, state){ state ->
imageState = state
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "image/*"
intent.putExtra(Intent.EXTRA_TITLE, File(state.uri.path).name)
startActivityForResult(intent, CREATE_IMAGE_FILE_REQ)
}
}
private fun renderBinary(state: GemState.ResponseBinary) = runOnUiThread{
loadingView(false)
binaryState = state
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = state.header.meta
intent.putExtra(Intent.EXTRA_TITLE, File(state.uri.path).name)
startActivityForResult(intent, CREATE_BINARY_FILE_REQ)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if(resultCode == RESULT_OK && (requestCode == CREATE_IMAGE_FILE_REQ || requestCode == CREATE_AUDIO_FILE_REQ || requestCode == CREATE_BINARY_FILE_REQ)){
//todo - tidy this mess up... refactor - none of this should be here
if(imageState == null && audioState == null && binaryState == null) return
data?.data?.let{ uri ->
val cachedFile = when {
imageState != null -> File(imageState!!.cacheUri.path ?: "")
audioState != null -> File(audioState!!.cacheUri.path ?: "")
binaryState != null -> File(binaryState!!.cacheUri.path ?: "")
else -> {
println("File download error - no state object exists")
showAlert("File download error - no state object exists")
null
}
}
cachedFile?.let{
contentResolver.openFileDescriptor(uri, "w")?.use { fileDescriptor ->
FileOutputStream(fileDescriptor.fileDescriptor).use { destOutput ->
val sourceChannel = FileInputStream(cachedFile).channel
val destChannel = destOutput.channel
sourceChannel.transferTo(0, sourceChannel.size(), destChannel)
sourceChannel.close()
destChannel.close()
cachedFile.deleteOnExit()
if(binaryState != null){
startActivity(Intent(DownloadManager.ACTION_VIEW_DOWNLOADS))
}else{
Snackbar.make(binding.root, "File saved to device", Snackbar.LENGTH_SHORT).show()
}
}
}
}
}
imageState = null
audioState = null
binaryState = null
}
}
private fun loadingView(visible: Boolean) = runOnUiThread {
binding.progressBar.visibleRetainingSpace(visible)
if(visible) binding.appBar.setExpanded(true)
}
override fun onBackPressed() {
if(model.canGoBack()){
model.goBack{ state ->
/*
Passing the callback here so we can eventually add a mechanism to restore scroll position
*/
handleState(state)
}
}else{
println("Ariane history is empty - exiting")
super.onBackPressed()
}
}
}