Compare commits
8 commits
1fbfc38d6f
...
d9802dc44d
Author | SHA1 | Date | |
---|---|---|---|
dece | d9802dc44d | ||
dece | 11854d4312 | ||
dece | d447370a41 | ||
dece | a76e84cf1d | ||
dece | fd5471b615 | ||
dece | fcf12f09d2 | ||
dece | 59e664ec5c | ||
dece | cd1732c599 |
|
@ -1,48 +1,71 @@
|
||||||
package dev.lowrespalmtree.comet
|
package dev.lowrespalmtree.comet
|
||||||
|
|
||||||
|
import dev.lowrespalmtree.comet.utils.*
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
/**
|
/** Test utils.Uri functions. Runs of the device due to the Uri functions being Android-only .*/
|
||||||
* Example local unit test, which will execute on the development machine (host).
|
|
||||||
*
|
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
|
||||||
*/
|
|
||||||
class UriUtilsTest {
|
class UriUtilsTest {
|
||||||
|
@Test
|
||||||
|
fun resolveLinkUri() {
|
||||||
|
// Absolute URLs.
|
||||||
|
assertEquals(
|
||||||
|
"gemini://example.com/",
|
||||||
|
resolveLinkUri("gemini://example.com", "gemini://dece.space/").toString()
|
||||||
|
)
|
||||||
|
// Relative links.
|
||||||
|
assertEquals(
|
||||||
|
"gemini://example.com/",
|
||||||
|
resolveLinkUri(".", "gemini://example.com/").toString()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"gemini://example.com/",
|
||||||
|
resolveLinkUri("..", "gemini://example.com/").toString()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"gemini://example.com/page",
|
||||||
|
resolveLinkUri("./page", "gemini://example.com/").toString()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"gemini://example.com/page",
|
||||||
|
resolveLinkUri("page", "gemini://example.com/").toString()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"gemini://example.com/page.com",
|
||||||
|
resolveLinkUri("page.com", "gemini://example.com/").toString()
|
||||||
|
)
|
||||||
|
// Scheme-less URLs.
|
||||||
|
assertEquals(
|
||||||
|
"gemini://someone.smol.pub/somepage",
|
||||||
|
resolveLinkUri("//someone.smol.pub/somepage", "gemini://smol.pub/feed").toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun joinUrls() {
|
fun joinUrls() {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"gemini://dece.space/some-file.gmi",
|
"gemini://dece.space/some-file.gmi",
|
||||||
dev.lowrespalmtree.comet.utils.joinUrls("gemini://dece.space/", "some-file.gmi").toString()
|
joinUrls("gemini://dece.space/", "some-file.gmi").toString()
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"gemini://dece.space/some-file.gmi",
|
"gemini://dece.space/some-file.gmi",
|
||||||
dev.lowrespalmtree.comet.utils.joinUrls("gemini://dece.space/", "./some-file.gmi").toString()
|
joinUrls("gemini://dece.space/", "./some-file.gmi").toString()
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"gemini://dece.space/some-file.gmi",
|
"gemini://dece.space/some-file.gmi",
|
||||||
dev.lowrespalmtree.comet.utils.joinUrls("gemini://dece.space/dir1", "/some-file.gmi").toString()
|
joinUrls("gemini://dece.space/dir1", "/some-file.gmi").toString()
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"gemini://dece.space/dir1/other-file.gmi",
|
"gemini://dece.space/dir1/other-file.gmi",
|
||||||
dev.lowrespalmtree.comet.utils.joinUrls(
|
joinUrls("gemini://dece.space/dir1/file.gmi", "other-file.gmi").toString()
|
||||||
"gemini://dece.space/dir1/file.gmi",
|
|
||||||
"other-file.gmi"
|
|
||||||
).toString()
|
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"gemini://dece.space/top-level.gmi",
|
"gemini://dece.space/top-level.gmi",
|
||||||
dev.lowrespalmtree.comet.utils.joinUrls(
|
joinUrls("gemini://dece.space/dir1/file.gmi", "../top-level.gmi").toString()
|
||||||
"gemini://dece.space/dir1/file.gmi",
|
|
||||||
"../top-level.gmi"
|
|
||||||
).toString()
|
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"s://hard/test/b/d/a.gmi",
|
"s://hard/test/b/d/a.gmi",
|
||||||
dev.lowrespalmtree.comet.utils.joinUrls(
|
joinUrls("s://hard/dir/a", "./../test/b/c/../d/e/f/../.././a.gmi").toString()
|
||||||
"s://hard/dir/a",
|
|
||||||
"./../test/b/c/../d/e/f/../.././a.gmi"
|
|
||||||
).toString()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,28 +81,28 @@ class UriUtilsTest {
|
||||||
Pair("mid/content=5/../6", "mid/6"),
|
Pair("mid/content=5/../6", "mid/6"),
|
||||||
Pair("../../../../g", "g")
|
Pair("../../../../g", "g")
|
||||||
).forEach { (path, expected) ->
|
).forEach { (path, expected) ->
|
||||||
assertEquals(expected, dev.lowrespalmtree.comet.utils.removeDotSegments(path))
|
assertEquals(expected, removeDotSegments(path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun removeLastSegment() {
|
fun removeLastSegment() {
|
||||||
assertEquals("", dev.lowrespalmtree.comet.utils.removeLastSegment(""))
|
assertEquals("", removeLastSegment(""))
|
||||||
assertEquals("", dev.lowrespalmtree.comet.utils.removeLastSegment("/"))
|
assertEquals("", removeLastSegment("/"))
|
||||||
assertEquals("", dev.lowrespalmtree.comet.utils.removeLastSegment("/a"))
|
assertEquals("", removeLastSegment("/a"))
|
||||||
assertEquals("/a", dev.lowrespalmtree.comet.utils.removeLastSegment("/a/"))
|
assertEquals("/a", removeLastSegment("/a/"))
|
||||||
assertEquals("/a", dev.lowrespalmtree.comet.utils.removeLastSegment("/a/b"))
|
assertEquals("/a", removeLastSegment("/a/b"))
|
||||||
assertEquals("/a/b/c", dev.lowrespalmtree.comet.utils.removeLastSegment("/a/b/c/d"))
|
assertEquals("/a/b/c", removeLastSegment("/a/b/c/d"))
|
||||||
assertEquals("//", dev.lowrespalmtree.comet.utils.removeLastSegment("///"))
|
assertEquals("//", removeLastSegment("///"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun popFirstSegment() {
|
fun popFirstSegment() {
|
||||||
assertEquals(Pair("", ""), dev.lowrespalmtree.comet.utils.popFirstSegment(""))
|
assertEquals(Pair("", ""), popFirstSegment(""))
|
||||||
assertEquals(Pair("a", ""), dev.lowrespalmtree.comet.utils.popFirstSegment("a"))
|
assertEquals(Pair("a", ""), popFirstSegment("a"))
|
||||||
assertEquals(Pair("/a", ""), dev.lowrespalmtree.comet.utils.popFirstSegment("/a"))
|
assertEquals(Pair("/a", ""), popFirstSegment("/a"))
|
||||||
assertEquals(Pair("/a", "/"), dev.lowrespalmtree.comet.utils.popFirstSegment("/a/"))
|
assertEquals(Pair("/a", "/"), popFirstSegment("/a/"))
|
||||||
assertEquals(Pair("/a", "/b"), dev.lowrespalmtree.comet.utils.popFirstSegment("/a/b"))
|
assertEquals(Pair("/a", "/b"), popFirstSegment("/a/b"))
|
||||||
assertEquals(Pair("a", "/b"), dev.lowrespalmtree.comet.utils.popFirstSegment("a/b"))
|
assertEquals(Pair("a", "/b"), popFirstSegment("a/b"))
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -9,8 +9,8 @@
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:fullBackupOnly="true"
|
android:fullBackupOnly="true"
|
||||||
android:icon="@mipmap/ic_logo"
|
android:icon="@mipmap/ic_logo"
|
||||||
android:roundIcon="@mipmap/ic_logo_round"
|
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_logo_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Comet">
|
android:theme="@style/Theme.Comet">
|
||||||
<activity
|
<activity
|
||||||
|
|
37
app/src/main/java/dev/lowrespalmtree/comet/MimeType.kt
Normal file
37
app/src/main/java/dev/lowrespalmtree/comet/MimeType.kt
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
package dev.lowrespalmtree.comet
|
||||||
|
|
||||||
|
class MimeType(
|
||||||
|
val main: String,
|
||||||
|
val sub: String,
|
||||||
|
val params: Map<String, String>
|
||||||
|
) {
|
||||||
|
val short: String get() = "${main.ifEmpty { "*" }}/${sub.ifEmpty { "*" }}"
|
||||||
|
val charset: String get() = params.getOrDefault("charset", DEFAULT_CHARSET)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_CHARSET = "utf-8"
|
||||||
|
val DEFAULT = MimeType("text", "gemini", mapOf("charset" to DEFAULT_CHARSET))
|
||||||
|
|
||||||
|
fun from(string: String): MimeType? {
|
||||||
|
val typeString: String
|
||||||
|
val params: Map<String, String>
|
||||||
|
if (";" in string) {
|
||||||
|
val elements = string.split(";")
|
||||||
|
typeString = elements[0]
|
||||||
|
params = mutableMapOf()
|
||||||
|
elements.subList(1, elements.size)
|
||||||
|
.map { it.trim().lowercase() }
|
||||||
|
.map { p -> if (p.count { it == '=' } != 1) return@from null else p }
|
||||||
|
.map { it.split('=') }
|
||||||
|
.forEach { params[it[0]] = it[1] }
|
||||||
|
} else {
|
||||||
|
typeString = string.trim()
|
||||||
|
params = mapOf()
|
||||||
|
}
|
||||||
|
if (typeString.count { it == '/' } != 1)
|
||||||
|
return null
|
||||||
|
val (main, sub) = typeString.split('/').map { it.trim() }
|
||||||
|
return MimeType(main, sub, params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,10 +17,10 @@ import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import dev.lowrespalmtree.comet.databinding.FragmentPageViewBinding
|
import dev.lowrespalmtree.comet.databinding.FragmentPageViewBinding
|
||||||
import dev.lowrespalmtree.comet.utils.isConnectedToNetwork
|
import dev.lowrespalmtree.comet.utils.isConnectedToNetwork
|
||||||
import dev.lowrespalmtree.comet.utils.joinUrls
|
import dev.lowrespalmtree.comet.utils.resolveLinkUri
|
||||||
import dev.lowrespalmtree.comet.utils.toGeminiUri
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
|
@ -107,13 +107,7 @@ class PageFragment : Fragment(), PageAdapter.Listener {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var uri = Uri.parse(url)
|
val uri = resolveLinkUri(url, base)
|
||||||
if (!uri.isAbsolute) {
|
|
||||||
uri = if (!base.isNullOrEmpty()) joinUrls(base, url) else toGeminiUri(uri)
|
|
||||||
} else if (uri.scheme == "gemini" && uri.path.isNullOrEmpty()) {
|
|
||||||
uri = uri.buildUpon().path("/").build()
|
|
||||||
}
|
|
||||||
|
|
||||||
when (uri.scheme) {
|
when (uri.scheme) {
|
||||||
"gemini" -> vm.sendGeminiRequest(uri, requireContext())
|
"gemini" -> vm.sendGeminiRequest(uri, requireContext())
|
||||||
else -> openUnknownScheme(uri)
|
else -> openUnknownScheme(uri)
|
||||||
|
@ -152,14 +146,44 @@ class PageFragment : Fragment(), PageAdapter.Listener {
|
||||||
return
|
return
|
||||||
when (event) {
|
when (event) {
|
||||||
is PageViewModel.InputEvent -> {
|
is PageViewModel.InputEvent -> {
|
||||||
askForInput(event.prompt, event.uri)
|
InputDialog(requireContext(), event.prompt.ifEmpty { "Input required" })
|
||||||
updateState(PageViewModel.State.IDLE)
|
.show(
|
||||||
|
onOk = { text ->
|
||||||
|
val newUri = event.uri.buildUpon().query(text).build()
|
||||||
|
openUrl(newUri.toString(), base = vm.currentUrl)
|
||||||
|
},
|
||||||
|
onDismiss = {}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
is PageViewModel.SuccessEvent -> {
|
is PageViewModel.SuccessEvent -> {
|
||||||
vm.currentUrl = event.uri
|
vm.currentUrl = event.uri
|
||||||
vm.visitedUrls.add(event.uri)
|
vm.visitedUrls.add(event.uri)
|
||||||
binding.addressBar.setText(event.uri)
|
binding.addressBar.setText(event.uri)
|
||||||
}
|
}
|
||||||
|
is PageViewModel.BinaryEvent -> {
|
||||||
|
// TODO this should present the user with options on what to do according to the
|
||||||
|
// MIME type: show inline, save in the media store, save as generic download, etc.
|
||||||
|
vm.downloadResponse(
|
||||||
|
event.response.data,
|
||||||
|
event.uri,
|
||||||
|
event.mimeType,
|
||||||
|
requireContext().contentResolver
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is PageViewModel.DownloadCompletedEvent -> {
|
||||||
|
val message = when (event.mimeType.main) {
|
||||||
|
"image" -> R.string.image_download_completed
|
||||||
|
else -> R.string.download_completed
|
||||||
|
}
|
||||||
|
Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT)
|
||||||
|
.setAction(R.string.open) {
|
||||||
|
startActivity(Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(event.uri, event.mimeType.short)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
vm.visitedUrls.add(event.uri.toString())
|
||||||
|
}
|
||||||
is PageViewModel.RedirectEvent -> {
|
is PageViewModel.RedirectEvent -> {
|
||||||
openUrl(event.uri, base = vm.currentUrl, redirections = event.redirects)
|
openUrl(event.uri, base = vm.currentUrl, redirections = event.redirects)
|
||||||
}
|
}
|
||||||
|
@ -170,7 +194,6 @@ class PageFragment : Fragment(), PageAdapter.Listener {
|
||||||
if (!isConnectedToNetwork(requireContext()))
|
if (!isConnectedToNetwork(requireContext()))
|
||||||
message += "\n\nInternet may be inaccessible…"
|
message += "\n\nInternet may be inaccessible…"
|
||||||
alert(message, title = event.short)
|
alert(message, title = event.short)
|
||||||
updateState(PageViewModel.State.IDLE)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
event.handled = true
|
event.handled = true
|
||||||
|
@ -184,17 +207,6 @@ class PageFragment : Fragment(), PageAdapter.Listener {
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun askForInput(prompt: String, uri: Uri) {
|
|
||||||
InputDialog(requireContext(), prompt.ifEmpty { "Input required" })
|
|
||||||
.show(
|
|
||||||
onOk = { text ->
|
|
||||||
val newUri = uri.buildUpon().query(text).build()
|
|
||||||
openUrl(newUri.toString(), base = vm.currentUrl)
|
|
||||||
},
|
|
||||||
onDismiss = {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openUnknownScheme(uri: Uri) {
|
private fun openUnknownScheme(uri: Uri) {
|
||||||
try {
|
try {
|
||||||
startActivity(Intent(Intent.ACTION_VIEW, uri))
|
startActivity(Intent(Intent.ACTION_VIEW, uri))
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package dev.lowrespalmtree.comet
|
package dev.lowrespalmtree.comet
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
@ -8,16 +9,19 @@ import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import dev.lowrespalmtree.comet.utils.joinUrls
|
import dev.lowrespalmtree.comet.utils.downloadMedia
|
||||||
|
import dev.lowrespalmtree.comet.utils.resolveLinkUri
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.channels.onSuccess
|
import kotlinx.coroutines.channels.onSuccess
|
||||||
import java.net.ConnectException
|
import java.net.ConnectException
|
||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
|
|
||||||
class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedStateHandle) :
|
class PageViewModel(
|
||||||
ViewModel() {
|
@Suppress("unused") private val savedStateHandle: SavedStateHandle
|
||||||
|
) : ViewModel() {
|
||||||
/** Currently viewed page URL. */
|
/** Currently viewed page URL. */
|
||||||
var currentUrl: String = ""
|
var currentUrl: String = ""
|
||||||
|
|
||||||
|
@ -42,14 +46,37 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
|
||||||
/** Lines for the current page. */
|
/** Lines for the current page. */
|
||||||
private var linesList = ArrayList<Line>()
|
private var linesList = ArrayList<Line>()
|
||||||
|
|
||||||
|
/** Page state to be reflected on the UI (e.g. loading bar). */
|
||||||
enum class State {
|
enum class State {
|
||||||
IDLE, CONNECTING, RECEIVING
|
IDLE, CONNECTING, RECEIVING
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generic event class to notify observers with. The handled flag avoids repeated usage. */
|
||||||
abstract class Event(var handled: Boolean = false)
|
abstract class Event(var handled: Boolean = false)
|
||||||
|
|
||||||
|
/** An user input has been requested from the URI, with this prompt. */
|
||||||
data class InputEvent(val uri: Uri, val prompt: String) : Event()
|
data class InputEvent(val uri: Uri, val prompt: String) : Event()
|
||||||
|
|
||||||
|
/** The server responded with a success code and *has finished* its response. */
|
||||||
data class SuccessEvent(val uri: String) : Event()
|
data class SuccessEvent(val uri: String) : Event()
|
||||||
|
|
||||||
|
/** The server responded with a success code and a binary MIME type (not delivered yet). */
|
||||||
|
data class BinaryEvent(
|
||||||
|
val uri: Uri,
|
||||||
|
val response: Response,
|
||||||
|
val mimeType: MimeType
|
||||||
|
) : Event()
|
||||||
|
|
||||||
|
/** A file has been completely downloaded. */
|
||||||
|
data class DownloadCompletedEvent(
|
||||||
|
val uri: Uri,
|
||||||
|
val mimeType: MimeType
|
||||||
|
) : Event()
|
||||||
|
|
||||||
|
/** The server is redirecting us. */
|
||||||
data class RedirectEvent(val uri: String, val redirects: Int) : Event()
|
data class RedirectEvent(val uri: String, val redirects: Int) : Event()
|
||||||
|
|
||||||
|
/** The server responded with a failure code or we encountered a local issue. */
|
||||||
data class FailureEvent(
|
data class FailureEvent(
|
||||||
val short: String,
|
val short: String,
|
||||||
val details: String,
|
val details: String,
|
||||||
|
@ -59,7 +86,9 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
|
||||||
/**
|
/**
|
||||||
* Perform a request against this URI.
|
* Perform a request against this URI.
|
||||||
*
|
*
|
||||||
* The URI must be valid, absolute and with a gemini scheme.
|
* @param uri URI to open; must be valid, absolute and with a gemini scheme
|
||||||
|
* @param context Context used to retrieve user preferences, not stored
|
||||||
|
* @param redirects current number of redirections operated
|
||||||
*/
|
*/
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
fun sendGeminiRequest(
|
fun sendGeminiRequest(
|
||||||
|
@ -136,16 +165,36 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Notify observers that an error happened, with a generic short message. Set state to idle. */
|
||||||
private fun signalError(message: String) {
|
private fun signalError(message: String) {
|
||||||
event.postValue(FailureEvent("Error", message))
|
event.postValue(FailureEvent("Error", message))
|
||||||
|
state.postValue(State.IDLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Notify observers that user input has been requested. */
|
||||||
private fun handleInputResponse(response: Response, uri: Uri) {
|
private fun handleInputResponse(response: Response, uri: Uri) {
|
||||||
event.postValue(InputEvent(uri, response.meta))
|
event.postValue(InputEvent(uri, response.meta))
|
||||||
|
state.postValue(State.IDLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Continue processing a successful response by looking at the provided MIME type. */
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
private suspend fun handleSuccessResponse(response: Response, uri: Uri) {
|
private suspend fun handleSuccessResponse(response: Response, uri: Uri) {
|
||||||
|
val mimeType = MimeType.from(response.meta) ?: MimeType.DEFAULT // Spec. section 3.3 last §
|
||||||
|
when (mimeType.main) {
|
||||||
|
"text" -> {
|
||||||
|
if (mimeType.sub == "gemini")
|
||||||
|
handleSuccessGemtextResponse(response, uri)
|
||||||
|
else
|
||||||
|
handleSuccessGenericTextResponse(response, uri)
|
||||||
|
}
|
||||||
|
else -> event.postValue(BinaryEvent(uri, response, mimeType))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Receive Gemtext data, parse it and send the lines to observers. */
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
private suspend fun handleSuccessGemtextResponse(response: Response, uri: Uri) {
|
||||||
state.postValue(State.RECEIVING)
|
state.postValue(State.RECEIVING)
|
||||||
val uriString = uri.toString()
|
val uriString = uri.toString()
|
||||||
|
|
||||||
|
@ -163,9 +212,7 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
|
||||||
lineChannelResult.onSuccess { line ->
|
lineChannelResult.onSuccess { line ->
|
||||||
if (line is LinkLine) {
|
if (line is LinkLine) {
|
||||||
// Mark visited links here as we have a access to the history.
|
// Mark visited links here as we have a access to the history.
|
||||||
val fullUrl =
|
val fullUrl = resolveLinkUri(line.url, uriString).toString()
|
||||||
if (Uri.parse(line.url).isAbsolute) line.url
|
|
||||||
else joinUrls(uriString, line.url).toString()
|
|
||||||
if (History.contains(fullUrl))
|
if (History.contains(fullUrl))
|
||||||
line.visited = true
|
line.visited = true
|
||||||
}
|
}
|
||||||
|
@ -195,15 +242,26 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
|
||||||
|
|
||||||
// We record the history entry here: it's nice because we have the main title available
|
// We record the history entry here: it's nice because we have the main title available
|
||||||
// and we're already in a coroutine for database access.
|
// and we're already in a coroutine for database access.
|
||||||
History.record(uri.toString(), mainTitle)
|
History.record(uriString, mainTitle)
|
||||||
event.postValue(SuccessEvent(uri.toString()))
|
event.postValue(SuccessEvent(uriString))
|
||||||
state.postValue(State.IDLE)
|
state.postValue(State.IDLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Receive generic text data (e.g. text/plain) and send it to observers. */
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
private suspend fun handleSuccessGenericTextResponse(response: Response, uri: Uri) =
|
||||||
|
handleSuccessGemtextResponse(response, uri) // TODO render plain text as... something else?
|
||||||
|
|
||||||
|
/** Notify observers that a redirect has been returned. */
|
||||||
private fun handleRedirectResponse(response: Response, redirects: Int) {
|
private fun handleRedirectResponse(response: Response, redirects: Int) {
|
||||||
event.postValue(RedirectEvent(response.meta, redirects))
|
event.postValue(RedirectEvent(response.meta, redirects))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide an error message to the user corresponding to the error code.
|
||||||
|
*
|
||||||
|
* TODO This requires a lot of localisation.
|
||||||
|
*/
|
||||||
private fun handleErrorResponse(response: Response) {
|
private fun handleErrorResponse(response: Response) {
|
||||||
val briefMessage = when (response.code) {
|
val briefMessage = when (response.code) {
|
||||||
Response.Code.TEMPORARY_FAILURE -> "40 Temporary failure"
|
Response.Code.TEMPORARY_FAILURE -> "40 Temporary failure"
|
||||||
|
@ -235,6 +293,33 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
|
||||||
if (response.code != Response.Code.SLOW_DOWN && response.meta.isNotEmpty())
|
if (response.code != Response.Code.SLOW_DOWN && response.meta.isNotEmpty())
|
||||||
serverMessage = response.meta
|
serverMessage = response.meta
|
||||||
event.postValue(FailureEvent(briefMessage, longMessage, serverMessage))
|
event.postValue(FailureEvent(briefMessage, longMessage, serverMessage))
|
||||||
|
state.postValue(State.IDLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download response content as a file. */
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
fun downloadResponse(
|
||||||
|
channel: Channel<ByteArray>,
|
||||||
|
uri: Uri,
|
||||||
|
mimeType: MimeType,
|
||||||
|
contentResolver: ContentResolver
|
||||||
|
) {
|
||||||
|
when (mimeType.main) {
|
||||||
|
"image", "audio", "video" -> {
|
||||||
|
downloadMedia(
|
||||||
|
channel, uri, mimeType, viewModelScope, contentResolver,
|
||||||
|
onSuccess = { mediaUri ->
|
||||||
|
event.postValue(DownloadCompletedEvent(mediaUri, mimeType))
|
||||||
|
state.postValue(State.IDLE)
|
||||||
|
},
|
||||||
|
onError = { msg -> signalError("Download failed: $msg") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// TODO use SAF
|
||||||
|
signalError("MIME type unsupported yet: ${mimeType.main} (\"${mimeType.short}\")")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import android.annotation.SuppressLint
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.BufferedInputStream
|
import java.io.BufferedInputStream
|
||||||
|
@ -19,6 +20,12 @@ import javax.net.ssl.*
|
||||||
class Request(private val uri: Uri, private val keyManager: KeyManager? = null) {
|
class Request(private val uri: Uri, private val keyManager: KeyManager? = null) {
|
||||||
private val port get() = if (uri.port > 0) uri.port else 1965
|
private val port get() = if (uri.port > 0) uri.port else 1965
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open and return the TLS socket with the server.
|
||||||
|
*
|
||||||
|
* If the server certificate present is fine according to our TOFU settings, the app can
|
||||||
|
* continue by calling `proceed` which will retrieve the data.
|
||||||
|
*/
|
||||||
fun connect(protocol: String, connectionTimeout: Int, readTimeout: Int): SSLSocket {
|
fun connect(protocol: String, connectionTimeout: Int, readTimeout: Int): SSLSocket {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
|
@ -34,18 +41,18 @@ class Request(private val uri: Uri, private val keyManager: KeyManager? = null)
|
||||||
return socket
|
return socket
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return a byte array channel carrying the data chunks sent by the server. */
|
||||||
fun proceed(socket: SSLSocket, scope: CoroutineScope): Channel<ByteArray> {
|
fun proceed(socket: SSLSocket, scope: CoroutineScope): Channel<ByteArray> {
|
||||||
Log.d(TAG, "proceed")
|
Log.d(TAG, "proceed")
|
||||||
socket.outputStream.write("$uri\r\n".toByteArray())
|
socket.outputStream.write("$uri\r\n".toByteArray())
|
||||||
|
|
||||||
val channel = Channel<ByteArray>()
|
val channel = Channel<ByteArray>()
|
||||||
scope.launch {
|
scope.launch(Dispatchers.IO) {
|
||||||
val buffer = ByteArray(1024)
|
val buffer = ByteArray(1024)
|
||||||
var numRead: Int
|
var numRead: Int
|
||||||
socket.inputStream.use { socket_input_stream ->
|
socket.inputStream.use { socket_input_stream ->
|
||||||
BufferedInputStream(socket_input_stream).use { bis ->
|
BufferedInputStream(socket_input_stream).use { bis ->
|
||||||
try {
|
try {
|
||||||
@Suppress("BlockingMethodInNonBlockingContext") // what u gonna do
|
|
||||||
while ((bis.read(buffer).also { numRead = it }) >= 0) {
|
while ((bis.read(buffer).also { numRead = it }) >= 0) {
|
||||||
val received = buffer.sliceArray(0 until numRead)
|
val received = buffer.sliceArray(0 until numRead)
|
||||||
channel.send(received)
|
channel.send(received)
|
||||||
|
@ -63,6 +70,13 @@ class Request(private val uri: Uri, private val keyManager: KeyManager? = null)
|
||||||
return channel
|
return channel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dummy KeyManager to be used when an client cert is to be used during the connection.
|
||||||
|
*
|
||||||
|
* This simply retrieves both the public cert and private key from the Android key store
|
||||||
|
* and implement dummy methods to return only this key pair. Some methods are left unimplemented
|
||||||
|
* because they should never be executed in the context we use the key manager in.
|
||||||
|
*/
|
||||||
class KeyManager(
|
class KeyManager(
|
||||||
private val alias: String,
|
private val alias: String,
|
||||||
private val cert: X509Certificate,
|
private val cert: X509Certificate,
|
||||||
|
|
124
app/src/main/java/dev/lowrespalmtree/comet/utils/Media.kt
Normal file
124
app/src/main/java/dev/lowrespalmtree/comet/utils/Media.kt
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
package dev.lowrespalmtree.comet.utils
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import dev.lowrespalmtree.comet.MimeType
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download media data from a ByteArray channel, into the media store.
|
||||||
|
*
|
||||||
|
* Run entirely in an IO coroutine. Convoluted because it tries to work properly with the Android 10
|
||||||
|
* MediaStore API and be backward-compatible as well. Still unsure it succeeds with both.
|
||||||
|
*
|
||||||
|
* @param channel incoming bytes of server response
|
||||||
|
* @param uri URI corresponding to the response
|
||||||
|
* @param mimeType MIME type parsed from the response's meta; MUST be either image, audio or video
|
||||||
|
* @param scope CoroutineScope to use for launching the download job
|
||||||
|
* @param contentResolver ContentResolver for MediaStore access
|
||||||
|
* @param onSuccess callback on completed download, with the saved media URI
|
||||||
|
* @param onError callback on error with a short message to show
|
||||||
|
*/
|
||||||
|
fun downloadMedia(
|
||||||
|
channel: Channel<ByteArray>,
|
||||||
|
uri: Uri,
|
||||||
|
mimeType: MimeType,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
contentResolver: ContentResolver,
|
||||||
|
onSuccess: (mediaUri: Uri) -> Unit,
|
||||||
|
onError: (message: String) -> Unit,
|
||||||
|
) {
|
||||||
|
val filename = uri.lastPathSegment.orEmpty().ifBlank { UUID.randomUUID().toString() }
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
// On Android Q and after, we use the proper MediaStore APIs. Proper…
|
||||||
|
val filetype = mimeType.main.also { assert(it in listOf("image", "audio", "video")) }
|
||||||
|
val mediaUri: Uri
|
||||||
|
if (isPostQ()) {
|
||||||
|
val details = ContentValues().apply {
|
||||||
|
put(getIsPendingCV(filetype), 1)
|
||||||
|
put(getDisplayNameCV(filetype), filename)
|
||||||
|
put(getRelativePathCV(filetype), getRelativePath(filetype))
|
||||||
|
}
|
||||||
|
mediaUri = contentResolver.insert(getContentUri(filetype), details)
|
||||||
|
?: return@launch Unit.also { onError("can't create local media file") }
|
||||||
|
contentResolver.openOutputStream(mediaUri)?.use { os ->
|
||||||
|
for (chunk in channel)
|
||||||
|
os.write(chunk)
|
||||||
|
} ?: return@launch Unit.also { onError("can't open output stream") }
|
||||||
|
details.clear()
|
||||||
|
details.put(getIsPendingCV(filetype), 0)
|
||||||
|
contentResolver.update(mediaUri, details, null, null)
|
||||||
|
}
|
||||||
|
// Before that, use the traditional clunky APIs. TODO test this stuff
|
||||||
|
else {
|
||||||
|
val collUri = getContentUri(filetype)
|
||||||
|
val outputFile = File(File(collUri.toFile(), "Comet"), filename)
|
||||||
|
FileOutputStream(outputFile).use { fos ->
|
||||||
|
for (chunk in channel)
|
||||||
|
fos.buffered().write(chunk)
|
||||||
|
}
|
||||||
|
mediaUri = outputFile.toUri()
|
||||||
|
}
|
||||||
|
onSuccess(mediaUri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the default external content URI for this file type. */
|
||||||
|
private fun getContentUri(type: String) =
|
||||||
|
when (type) {
|
||||||
|
"image" -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||||
|
"audio" -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||||
|
"video" -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||||
|
else -> throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the display name *content value string identifier* for this file type. */
|
||||||
|
private fun getDisplayNameCV(type: String) =
|
||||||
|
when (type) {
|
||||||
|
"image" -> MediaStore.Images.Media.DISPLAY_NAME
|
||||||
|
"audio" -> MediaStore.Audio.Media.DISPLAY_NAME
|
||||||
|
"video" -> MediaStore.Video.Media.DISPLAY_NAME
|
||||||
|
else -> throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the isPending flag *content value string identifier* for this file type. */
|
||||||
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
|
private fun getIsPendingCV(type: String) =
|
||||||
|
when (type) {
|
||||||
|
"image" -> MediaStore.Images.Media.IS_PENDING
|
||||||
|
"audio" -> MediaStore.Audio.Media.IS_PENDING
|
||||||
|
"video" -> MediaStore.Video.Media.IS_PENDING
|
||||||
|
else -> throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the relative path *content value string identifier* for this file type. */
|
||||||
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
|
private fun getRelativePathCV(type: String) =
|
||||||
|
when (type) {
|
||||||
|
"image" -> MediaStore.Images.Media.RELATIVE_PATH
|
||||||
|
"audio" -> MediaStore.Audio.Media.RELATIVE_PATH
|
||||||
|
"video" -> MediaStore.Video.Media.RELATIVE_PATH
|
||||||
|
else -> throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the actual relative path for this file type, usually standard with a "Comet" subfolder. */
|
||||||
|
private fun getRelativePath(type: String) =
|
||||||
|
when (type) {
|
||||||
|
"image" -> Environment.DIRECTORY_PICTURES
|
||||||
|
"audio" -> Environment.DIRECTORY_MUSIC // TODO should be a user choice
|
||||||
|
"video" -> Environment.DIRECTORY_MOVIES
|
||||||
|
else -> throw UnsupportedOperationException()
|
||||||
|
} + "/Comet"
|
|
@ -0,0 +1,6 @@
|
||||||
|
package dev.lowrespalmtree.comet.utils
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
/** Return true if the device is running Android 10 ("Q") or higher. */
|
||||||
|
fun isPostQ() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
|
@ -2,6 +2,26 @@ package dev.lowrespalmtree.comet.utils
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the URI of a link found on a page.
|
||||||
|
*
|
||||||
|
* Links can take various forms: absolute links to a page on a capsule, relative links on the same
|
||||||
|
* capsule, but also fancy scheme-less absolute URLs (i.e. starting with "//") for cross-protocol
|
||||||
|
* linking. This function returns the resolved URI from any type of link, opt. using current URL.
|
||||||
|
*/
|
||||||
|
fun resolveLinkUri(url: String, base: String?): Uri {
|
||||||
|
var uri = Uri.parse(url)
|
||||||
|
if (!uri.isAbsolute) {
|
||||||
|
uri =
|
||||||
|
if (url.startsWith("//")) uri.buildUpon().scheme("gemini").build()
|
||||||
|
else if (!base.isNullOrEmpty()) joinUrls(base, url)
|
||||||
|
else toGeminiUri(uri)
|
||||||
|
} else if (uri.scheme == "gemini" && uri.path.isNullOrEmpty()) {
|
||||||
|
uri = uri.buildUpon().path("/").build()
|
||||||
|
}
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform a relative URI to an absolute Gemini URI
|
* Transform a relative URI to an absolute Gemini URI
|
||||||
*
|
*
|
||||||
|
|
|
@ -47,5 +47,8 @@
|
||||||
<string name="identity_usages">Active URL path</string>
|
<string name="identity_usages">Active URL path</string>
|
||||||
<string name="input_common_name">Enter a name to use as the certificate\'s subject common name. This can be left empty.</string>
|
<string name="input_common_name">Enter a name to use as the certificate\'s subject common name. This can be left empty.</string>
|
||||||
<string name="tls_version">TLS version</string>
|
<string name="tls_version">TLS version</string>
|
||||||
|
<string name="open">Open</string>
|
||||||
|
<string name="download_completed">File downloaded.</string>
|
||||||
|
<string name="image_download_completed">Image downloaded.</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
38
app/src/test/java/dev/lowrespalmtree/comet/MimeTypeTests.kt
Normal file
38
app/src/test/java/dev/lowrespalmtree/comet/MimeTypeTests.kt
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
package dev.lowrespalmtree.comet
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class MimeTypeTests {
|
||||||
|
@Test
|
||||||
|
fun from() {
|
||||||
|
assertNull(MimeType.from(""))
|
||||||
|
assertNull(MimeType.from("dumb"))
|
||||||
|
assertNull(MimeType.from("dumb;dumber"))
|
||||||
|
assertNull(MimeType.from("123456"))
|
||||||
|
|
||||||
|
MimeType.from("a/b")?.run {
|
||||||
|
assertEquals("a", main)
|
||||||
|
assertEquals("b", sub)
|
||||||
|
assertEquals(mapOf<String, String>(), params)
|
||||||
|
} ?: fail()
|
||||||
|
|
||||||
|
MimeType.from("text/gemini")?.run {
|
||||||
|
assertEquals("text", main)
|
||||||
|
assertEquals("gemini", sub)
|
||||||
|
assertEquals(mapOf<String, String>(), params)
|
||||||
|
} ?: fail()
|
||||||
|
|
||||||
|
MimeType.from("text/gemini;lang=en")?.run {
|
||||||
|
assertEquals("text", main)
|
||||||
|
assertEquals("gemini", sub)
|
||||||
|
assertEquals(mapOf("lang" to "en"), params)
|
||||||
|
} ?: fail()
|
||||||
|
|
||||||
|
MimeType.from("text/gemini ;lang=en")?.run {
|
||||||
|
assertEquals("text", main)
|
||||||
|
assertEquals("gemini", sub)
|
||||||
|
assertEquals(mapOf("lang" to "en"), params)
|
||||||
|
} ?: fail()
|
||||||
|
}
|
||||||
|
}
|
Reference in a new issue