file download...

This commit is contained in:
Jonathan Fisher 2020-11-13 15:38:57 +00:00
parent 08842fd316
commit 3ff4758bbe
11 changed files with 134 additions and 131 deletions

View File

@ -39,6 +39,17 @@
android:label="@string/settings" android:label="@string/settings"
android:theme="@style/SettingsTheme"/> android:theme="@style/SettingsTheme"/>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@ -14,6 +14,8 @@ sealed class GemState {
data class ResponseText(val uri: URI, val header: GeminiResponse.Header, val content: String) : GemState() data class ResponseText(val uri: URI, val header: GeminiResponse.Header, val content: String) : GemState()
data class ResponseImage(val uri: URI, val header: GeminiResponse.Header, val cacheUri: Uri) : GemState() data class ResponseImage(val uri: URI, val header: GeminiResponse.Header, val cacheUri: Uri) : GemState()
data class ResponseAudio(val uri: URI, val header: GeminiResponse.Header, val cacheUri: Uri) : GemState() data class ResponseAudio(val uri: URI, val header: GeminiResponse.Header, val cacheUri: Uri) : GemState()
data class ResponseBinary(val uri: URI, val header: GeminiResponse.Header, val cacheUri: Uri) : GemState()
data class ResponseUnknownMime(val uri: URI, val header: GeminiResponse.Header) : GemState()
data class ResponseError(val header: GeminiResponse.Header): GemState() data class ResponseError(val header: GeminiResponse.Header): GemState()
object Blank: GemState() object Blank: GemState()

View File

@ -5,6 +5,7 @@ import oppen.ariane.io.GemState
import java.net.URI import java.net.URI
interface Datasource { interface Datasource {
fun request(uri: URI, forceDownload: Boolean, onUpdate: (state: GemState) -> Unit)
fun request(uri: URI, onUpdate: (state: GemState) -> Unit) fun request(uri: URI, onUpdate: (state: GemState) -> Unit)
companion object{ companion object{

View File

@ -16,12 +16,6 @@ import javax.net.ssl.*
const val GEMINI_SCHEME = "gemini" const val GEMINI_SCHEME = "gemini"
/**
*
*
* @param protocol see: https://docs.oracle.com/javase/7/docs/technotes/guides/security/StandardNames.html#SSLContext
*
*/
class GeminiDatasource( class GeminiDatasource(
private val context: Context): Datasource { private val context: Context): Datasource {
@ -30,8 +24,12 @@ class GeminiDatasource(
private val addressBuilder = AddressBuilder() private val addressBuilder = AddressBuilder()
override fun request(uri: URI, onUpdate: (state: GemState) -> Unit) { private var forceDownload = false
override fun request(uri: URI, onUpdate: (state: GemState) -> Unit) = request(uri, false, onUpdate)
override fun request(uri: URI, forceDownload: Boolean, onUpdate: (state: GemState) -> Unit) {
this.forceDownload = forceDownload
//Any inputted uri starting with a colon is an app-specific command, eg. :prefs :settings //Any inputted uri starting with a colon is an app-specific command, eg. :prefs :settings
if(uri.toString().startsWith(":")){ if(uri.toString().startsWith(":")){
onUpdate(GemState.AppQuery(uri)) onUpdate(GemState.AppQuery(uri))
@ -63,15 +61,9 @@ class GeminiDatasource(
val cached = RuntimeCache.get(parsedUri) val cached = RuntimeCache.get(parsedUri)
if(cached != null){ if(cached != null){
last = parsedUri.toURI() last = parsedUri.toURI()
onUpdate( onUpdate(GemState.ResponseGemtext(parsedUri.toURI(), cached.first, cached.second))
GemState.ResponseGemtext(
parsedUri.toURI(),
cached.first,
cached.second
)
)
}else{ }else{
request(parsedUri.toURI(), onUpdate) request(parsedUri.toURI(), forceDownload, onUpdate)
} }
}else{ }else{
onUpdate(GemState.NotGeminiRequest(uri)) onUpdate(GemState.NotGeminiRequest(uri))
@ -97,10 +89,9 @@ class GeminiDatasource(
println("REQ_PROTOCOL: $protocol") println("REQ_PROTOCOL: $protocol")
//todo - extract and reuse this //todo - extract and reuse this
val sslContext = if(protocol == "TLS_ALL"){ val sslContext = when (protocol) {
SSLContext.getInstance("TLS") "TLS_ALL" -> SSLContext.getInstance("TLS")
}else{ else -> SSLContext.getInstance(protocol)
SSLContext.getInstance(protocol)
} }
sslContext.init(null, DummyTrustManager.get(), null) sslContext.init(null, DummyTrustManager.get(), null)
@ -119,25 +110,11 @@ class GeminiDatasource(
socket.startHandshake() socket.startHandshake()
}catch (ce: ConnectException){ }catch (ce: ConnectException){
println("socket error: $ce") println("socket error: $ce")
onUpdate( onUpdate(GemState.ResponseError(GeminiResponse.Header(-1, ce.message ?: ce.toString())))
GemState.ResponseError(
GeminiResponse.Header(
-1,
ce.message ?: ce.toString()
)
)
)
return return
}catch (she: SSLHandshakeException){ }catch (she: SSLHandshakeException){
println("socket error: $she") println("socket error: $she")
onUpdate( onUpdate(GemState.ResponseError(GeminiResponse.Header(-2, she.message ?: she.toString())))
GemState.ResponseError(
GeminiResponse.Header(
-2,
she.message ?: she.toString()
)
)
)
return return
} }
@ -151,14 +128,7 @@ class GeminiDatasource(
outWriter.flush() outWriter.flush()
if (outWriter.checkError()) { if (outWriter.checkError()) {
onUpdate( onUpdate(GemState.ResponseError(GeminiResponse.Header(-1, "Print Writer Error")))
GemState.ResponseError(
GeminiResponse.Header(
-1,
"Print Writer Error"
)
)
)
outWriter.close() outWriter.close()
return return
} }
@ -176,18 +146,21 @@ class GeminiDatasource(
when { when {
header.code == GeminiResponse.INPUT -> onUpdate(GemState.ResponseInput(uri, header)) header.code == GeminiResponse.INPUT -> onUpdate(GemState.ResponseInput(uri, header))
header.code == GeminiResponse.REDIRECT -> request(URI.create(header.meta), onUpdate) header.code == GeminiResponse.REDIRECT -> request(URI.create(header.meta), forceDownload, onUpdate)
header.code != GeminiResponse.SUCCESS -> onUpdate(GemState.ResponseError(header)) header.code != GeminiResponse.SUCCESS -> onUpdate(GemState.ResponseError(header))
header.meta.startsWith("text/gemini") -> getGemtext( header.meta.startsWith("text/gemini") -> getGemtext(bufferedReader, uri, header, onUpdate)
bufferedReader,
uri,
header,
onUpdate
)
header.meta.startsWith("text/") -> getString(socket, uri, header, onUpdate) header.meta.startsWith("text/") -> getString(socket, uri, header, onUpdate)
header.meta.startsWith("image/") -> getBinary(socket, uri, header, onUpdate) header.meta.startsWith("image/") -> getBinary(socket, uri, header, onUpdate)
header.meta.startsWith("audio/") -> getBinary(socket, uri, header, onUpdate) header.meta.startsWith("audio/") -> getBinary(socket, uri, header, onUpdate)
else -> onUpdate(GemState.ResponseError(header)) else -> {
//File served over Gemini but not handled in-app, eg .pdf
if(forceDownload){
getBinary(socket, uri, header, onUpdate)
}else{
onUpdate(GemState.ResponseUnknownMime(uri, header))
}
}
} }
//Close input //Close input
@ -202,12 +175,7 @@ class GeminiDatasource(
socket.close() socket.close()
} }
private fun getGemtext( private fun getGemtext(reader: BufferedReader, uri: URI, header: GeminiResponse.Header, onUpdate: (state: GemState) -> Unit){
reader: BufferedReader,
uri: URI,
header: GeminiResponse.Header,
onUpdate: (state: GemState) -> Unit
){
val lines = mutableListOf<String>() val lines = mutableListOf<String>()
@ -222,28 +190,21 @@ class GeminiDatasource(
onUpdate(GemState.ResponseGemtext(uri, header, processed)) onUpdate(GemState.ResponseGemtext(uri, header, processed))
} }
private fun getString( private fun getString(socket: SSLSocket?, uri: URI, header: GeminiResponse.Header, onUpdate: (state: GemState) -> Unit){
socket: SSLSocket?, val content = socket?.inputStream?.bufferedReader().use {
uri: URI, reader -> reader?.readText()
header: GeminiResponse.Header, }
onUpdate: (state: GemState) -> Unit
){
val content = socket?.inputStream?.bufferedReader().use { reader -> reader?.readText() }
socket?.close() socket?.close()
onUpdate(GemState.ResponseText(uri, header, content ?: "Error fetching content")) onUpdate(GemState.ResponseText(uri, header, content ?: "Error fetching content"))
} }
private fun getBinary( private fun getBinary(socket: SSLSocket?, uri: URI, header: GeminiResponse.Header, onUpdate: (state: GemState) -> Unit){
socket: SSLSocket?,
uri: URI,
header: GeminiResponse.Header,
onUpdate: (state: GemState) -> Unit
){
var filename: String? = null var filename: String? = null
val fileSegmentIndex: Int = uri.path.lastIndexOf('/') val fileSegmentIndex: Int = uri.path.lastIndexOf('/')
if (fileSegmentIndex != -1) {
filename = uri.path.substring(fileSegmentIndex + 1) when {
fileSegmentIndex != -1 -> filename = uri.path.substring(fileSegmentIndex + 1)
} }
val host = uri.host.replace(".", "_") val host = uri.host.replace(".", "_")
@ -255,22 +216,10 @@ class GeminiDatasource(
when { when {
cacheFile.exists() -> { cacheFile.exists() -> {
when { when {
header.meta.startsWith("image/") -> onUpdate( header.meta.startsWith("image/") -> onUpdate(GemState.ResponseImage(uri, header, cacheFile.toUri()))
GemState.ResponseImage( header.meta.startsWith("audio/") -> onUpdate(GemState.ResponseAudio(uri, header, cacheFile.toUri()))
uri, else -> onUpdate(GemState.ResponseBinary(uri, header, cacheFile.toUri()))
header,
cacheFile.toUri()
)
)
header.meta.startsWith("audio/") -> onUpdate(
GemState.ResponseAudio(
uri,
header,
cacheFile.toUri()
)
)
} }
} }
else -> { else -> {
cacheFile.createNewFile() cacheFile.createNewFile()
@ -280,20 +229,9 @@ class GeminiDatasource(
} }
when { when {
header.meta.startsWith("image/") -> onUpdate( header.meta.startsWith("image/") -> onUpdate(GemState.ResponseImage(uri, header, cacheFile.toUri()))
GemState.ResponseImage( header.meta.startsWith("audio/") -> onUpdate(GemState.ResponseAudio(uri, header, cacheFile.toUri()))
uri, else -> onUpdate(GemState.ResponseBinary(uri, header, cacheFile.toUri()))
header,
cacheFile.toUri()
)
)
header.meta.startsWith("audio/") -> onUpdate(
GemState.ResponseAudio(
uri,
header,
cacheFile.toUri()
)
)
} }
} }
} }

View File

@ -1,6 +1,8 @@
package oppen.ariane.ui package oppen.ariane.ui
import android.app.DownloadManager
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.media.MediaPlayer import android.media.MediaPlayer
import android.net.Uri import android.net.Uri
@ -34,6 +36,7 @@ import oppen.ariane.ui.modals_menus.input.InputDialog
import oppen.ariane.ui.modals_menus.overflow.OverflowPopup import oppen.ariane.ui.modals_menus.overflow.OverflowPopup
import oppen.ariane.ui.settings.SettingsActivity import oppen.ariane.ui.settings.SettingsActivity
import oppen.hideKeyboard import oppen.hideKeyboard
import oppen.toURI
import oppen.visibleRetainingSpace import oppen.visibleRetainingSpace
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
@ -42,6 +45,7 @@ import java.net.URLEncoder
const val CREATE_IMAGE_FILE_REQ = 628 const val CREATE_IMAGE_FILE_REQ = 628
const val CREATE_AUDIO_FILE_REQ = 629 const val CREATE_AUDIO_FILE_REQ = 629
const val CREATE_BINARY_FILE_REQ = 630
class GemActivity : AppCompatActivity() { class GemActivity : AppCompatActivity() {
@ -126,10 +130,25 @@ class GemActivity : AppCompatActivity() {
is GemState.ResponseText -> renderText(state) is GemState.ResponseText -> renderText(state)
is GemState.ResponseImage -> renderImage(state) is GemState.ResponseImage -> renderImage(state)
is GemState.ResponseAudio -> renderAudio(state) is GemState.ResponseAudio -> renderAudio(state)
is GemState.ResponseBinary -> renderBinary(state)
is GemState.Blank -> { is GemState.Blank -> {
binding.addressEdit.setText("") binding.addressEdit.setText("")
adapter.render(arrayListOf()) 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()
}
}
} }
} }
@ -138,17 +157,9 @@ class GemActivity : AppCompatActivity() {
EditorInfo.IME_ACTION_GO -> { EditorInfo.IME_ACTION_GO -> {
val input = binding.addressEdit.text.toString() val input = binding.addressEdit.text.toString()
if (input.startsWith("gemini://")) { when {
model.request(input) input.startsWith("gemini://") -> model.request(input)
} else { else -> model.request("${Ariane.GEMINI_USER_SEARCH_BASE}${URLEncoder.encode(input, "UTF-8")}")
model.request(
"${Ariane.GEMINI_USER_SEARCH_BASE}${
URLEncoder.encode(
input,
"UTF-8"
)
}"
)
} }
binding.addressEdit.hideKeyboard() binding.addressEdit.hideKeyboard()
@ -310,6 +321,7 @@ class GemActivity : AppCompatActivity() {
var imageState: GemState.ResponseImage? = null var imageState: GemState.ResponseImage? = null
var audioState: GemState.ResponseAudio? = null var audioState: GemState.ResponseAudio? = null
var binaryState: GemState.ResponseBinary? = null
private fun renderAudio(state: GemState.ResponseAudio) = runOnUiThread { private fun renderAudio(state: GemState.ResponseAudio) = runOnUiThread {
loadingView(false) loadingView(false)
@ -335,36 +347,57 @@ class GemActivity : AppCompatActivity() {
} }
} }
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?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
if(resultCode == RESULT_OK && (requestCode == CREATE_IMAGE_FILE_REQ || requestCode == CREATE_AUDIO_FILE_REQ)){ if(resultCode == RESULT_OK && (requestCode == CREATE_IMAGE_FILE_REQ || requestCode == CREATE_AUDIO_FILE_REQ || requestCode == CREATE_BINARY_FILE_REQ)){
if(imageState == null && audioState == null) return
//todo - tidy this mess up... //todo - tidy this mess up...
if(imageState == null && audioState == null && binaryState == null) return
data?.data?.let{ uri -> data?.data?.let{ uri ->
val cachedFile = when { val cachedFile = when {
imageState != null -> { imageState != null -> File(imageState!!.cacheUri.path ?: "")
File(imageState!!.cacheUri.path ?: "") audioState != null -> File(audioState!!.cacheUri.path ?: "")
} binaryState != null -> File(binaryState!!.cacheUri.path ?: "")
else -> { else -> {
File(audioState!!.cacheUri.path ?: "") println("File download error - no state object exists")
showAlert("File download error - no state object exists")
null
} }
} }
contentResolver.openFileDescriptor(uri, "w")?.use { fileDescriptor ->
FileOutputStream(fileDescriptor.fileDescriptor).use { destOutput -> cachedFile?.let{
val sourceChannel = FileInputStream(cachedFile).channel contentResolver.openFileDescriptor(uri, "w")?.use { fileDescriptor ->
val destChannel = destOutput.channel FileOutputStream(fileDescriptor.fileDescriptor).use { destOutput ->
sourceChannel.transferTo(0, sourceChannel.size(), destChannel) val sourceChannel = FileInputStream(cachedFile).channel
sourceChannel.close() val destChannel = destOutput.channel
destChannel.close() 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()
}
}
} }
} }
} }
Snackbar.make(binding.root, "File saved to device", Snackbar.LENGTH_SHORT).show()
imageState = null imageState = null
audioState = null audioState = null
binaryState = null
} }
} }

View File

@ -43,6 +43,13 @@ class GemViewModel: ViewModel() {
} }
} }
fun requestBinaryDownload(uri: URI) {
gemini.request(uri, true){ state ->
onState(state)
}
}
//todo - same action as above... refactor
fun requestInlineImage(uri: URI, onImageReady: (cacheUri: Uri?) -> Unit){ fun requestInlineImage(uri: URI, onImageReady: (cacheUri: Uri?) -> Unit){
gemini.request(uri){ state -> gemini.request(uri){ state ->
when (state) { when (state) {

View File

@ -1,12 +1,10 @@
package oppen.ariane.ui.settings package oppen.ariane.ui.settings
import android.os.Bundle import android.os.Bundle
import android.text.InputType
import android.text.Spannable import android.text.Spannable
import android.text.SpannableString import android.text.SpannableString
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.widget.EditText
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.preference.* import androidx.preference.*
import oppen.ariane.Ariane import oppen.ariane.Ariane
@ -16,7 +14,6 @@ import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory import javax.net.ssl.SSLSocketFactory
class SettingsFragment: PreferenceFragmentCompat(), Preference.OnPreferenceChangeListener { class SettingsFragment: PreferenceFragmentCompat(), Preference.OnPreferenceChangeListener {
lateinit var protocols: Array<String> lateinit var protocols: Array<String>

View File

@ -17,4 +17,6 @@
<style name="DayNightDialog" parent="Theme.AppCompat.DayNight.Dialog.Alert"/> <style name="DayNightDialog" parent="Theme.AppCompat.DayNight.Dialog.Alert"/>
<style name="PrefsDialogTheme"></style>
</resources> </resources>

View File

@ -36,4 +36,5 @@
<string name="delete">Delete</string> <string name="delete">Delete</string>
<string name="move_down">Move down</string> <string name="move_down">Move down</string>
<string name="move_up">Move up</string> <string name="move_up">Move up</string>
<string name="unknown_mime_dialog_title">Unknown Mime Type</string>
</resources> </resources>

View File

@ -21,6 +21,13 @@
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item> <item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
</style> </style>
<style name="AppDialogTheme" parent="Theme.MaterialComponents.DayNight.Dialog.Alert">
<item name="colorPrimary">@color/colorAccent</item>
<item name="colorPrimaryDark">@color/colorAccent</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="textAllCaps">false</item>
</style>
<style name="FSDialog" parent="@style/AppTheme"> <style name="FSDialog" parent="@style/AppTheme">
</style> </style>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="external_files" path="."/>
</paths>