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

413 lines
16 KiB
Kotlin

package oppen.ariane.ui
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.DialogInterface
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.bookmarks.BookmarksDatasource
import oppen.ariane.io.gemini.Datasource
import oppen.ariane.io.gemini.GeminiResponse
import oppen.ariane.io.gemini.RuntimeCache
import oppen.ariane.io.history.uris.HistoryInterface
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.toURI
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 lateinit var history: HistoryInterface
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)
bookmarkDatasource = BookmarksDatasource.getDefault(applicationContext)
binding = DataBindingUtil.setContentView(this, R.layout.activity_gem)
binding.viewmodel = model
binding.lifecycleOwner = this
binding.gemtextRecycler.layoutManager = LinearLayoutManager(this)
binding.gemtextRecycler.adapter = adapter
history = HistoryInterface.default(this)
model.initialise(
home = PreferenceManager.getDefaultSharedPreferences(this).getString("home_capsule", Ariane.DEFAULT_HOME_CAPSULE) ?: Ariane.DEFAULT_HOME_CAPSULE,
gemini = Datasource.factory(this),
bookmarks = BookmarksDatasource.getDefault(applicationContext)
){ state ->
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()
}
}
}
}
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) { 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)
}
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)
}
history.add(state.uri.toString())
}
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)
}
@ExperimentalStdlibApi
override fun onBackPressed() {
if(model.canGoBack()){
model.goBack()
}else{
println("Ariane history is empty - exiting")
super.onBackPressed()
}
}
}