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() 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() cacheDir.deleteRecursively() } } }