Compare commits

...

8 commits

Author SHA1 Message Date
dece d9802dc44d PageViewModel: hande audio and video links
Big first step in issue #11.
2022-02-17 00:46:07 +01:00
dece 11854d4312 PageViewModel: download images to media store 2022-02-16 23:36:35 +01:00
dece d447370a41 MimeType: use it to process responses accordingly
This prevents Comet from showing JPGs as Gemtext, whew… Any unhandled
MIME type now presents an alert, and we should slowly support more and
more types.
2022-02-16 11:48:23 +01:00
dece a76e84cf1d UriUtilsTest: clean 2022-02-16 11:06:52 +01:00
dece fd5471b615 Uri: add resolveLinkUri with tests 2022-02-16 11:06:26 +01:00
dece fcf12f09d2 Request: fix coroutine pool for server data 2022-02-15 17:09:25 +01:00
dece 59e664ec5c clean and add some docs 2022-02-15 17:09:11 +01:00
dece cd1732c599 MimeType: add class with tests, woah! 2022-02-15 17:08:39 +01:00
11 changed files with 431 additions and 69 deletions

View file

@ -1,48 +1,71 @@
package dev.lowrespalmtree.comet
import dev.lowrespalmtree.comet.utils.*
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
/** Test utils.Uri functions. Runs of the device due to the Uri functions being Android-only .*/
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
fun joinUrls() {
assertEquals(
"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(
"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(
"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(
"gemini://dece.space/dir1/other-file.gmi",
dev.lowrespalmtree.comet.utils.joinUrls(
"gemini://dece.space/dir1/file.gmi",
"other-file.gmi"
).toString()
joinUrls("gemini://dece.space/dir1/file.gmi", "other-file.gmi").toString()
)
assertEquals(
"gemini://dece.space/top-level.gmi",
dev.lowrespalmtree.comet.utils.joinUrls(
"gemini://dece.space/dir1/file.gmi",
"../top-level.gmi"
).toString()
joinUrls("gemini://dece.space/dir1/file.gmi", "../top-level.gmi").toString()
)
assertEquals(
"s://hard/test/b/d/a.gmi",
dev.lowrespalmtree.comet.utils.joinUrls(
"s://hard/dir/a",
"./../test/b/c/../d/e/f/../.././a.gmi"
).toString()
joinUrls("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("../../../../g", "g")
).forEach { (path, expected) ->
assertEquals(expected, dev.lowrespalmtree.comet.utils.removeDotSegments(path))
assertEquals(expected, removeDotSegments(path))
}
}
@Test
fun removeLastSegment() {
assertEquals("", dev.lowrespalmtree.comet.utils.removeLastSegment(""))
assertEquals("", dev.lowrespalmtree.comet.utils.removeLastSegment("/"))
assertEquals("", dev.lowrespalmtree.comet.utils.removeLastSegment("/a"))
assertEquals("/a", dev.lowrespalmtree.comet.utils.removeLastSegment("/a/"))
assertEquals("/a", dev.lowrespalmtree.comet.utils.removeLastSegment("/a/b"))
assertEquals("/a/b/c", dev.lowrespalmtree.comet.utils.removeLastSegment("/a/b/c/d"))
assertEquals("//", dev.lowrespalmtree.comet.utils.removeLastSegment("///"))
assertEquals("", removeLastSegment(""))
assertEquals("", removeLastSegment("/"))
assertEquals("", removeLastSegment("/a"))
assertEquals("/a", removeLastSegment("/a/"))
assertEquals("/a", removeLastSegment("/a/b"))
assertEquals("/a/b/c", removeLastSegment("/a/b/c/d"))
assertEquals("//", removeLastSegment("///"))
}
@Test
fun popFirstSegment() {
assertEquals(Pair("", ""), dev.lowrespalmtree.comet.utils.popFirstSegment(""))
assertEquals(Pair("a", ""), dev.lowrespalmtree.comet.utils.popFirstSegment("a"))
assertEquals(Pair("/a", ""), dev.lowrespalmtree.comet.utils.popFirstSegment("/a"))
assertEquals(Pair("/a", "/"), dev.lowrespalmtree.comet.utils.popFirstSegment("/a/"))
assertEquals(Pair("/a", "/b"), dev.lowrespalmtree.comet.utils.popFirstSegment("/a/b"))
assertEquals(Pair("a", "/b"), dev.lowrespalmtree.comet.utils.popFirstSegment("a/b"))
assertEquals(Pair("", ""), popFirstSegment(""))
assertEquals(Pair("a", ""), popFirstSegment("a"))
assertEquals(Pair("/a", ""), popFirstSegment("/a"))
assertEquals(Pair("/a", "/"), popFirstSegment("/a/"))
assertEquals(Pair("/a", "/b"), popFirstSegment("/a/b"))
assertEquals(Pair("a", "/b"), popFirstSegment("a/b"))
}
}

View file

@ -9,8 +9,8 @@
android:allowBackup="true"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_logo"
android:roundIcon="@mipmap/ic_logo_round"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_logo_round"
android:supportsRtl="true"
android:theme="@style/Theme.Comet">
<activity

View 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)
}
}
}

View file

@ -17,10 +17,10 @@ import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import dev.lowrespalmtree.comet.databinding.FragmentPageViewBinding
import dev.lowrespalmtree.comet.utils.isConnectedToNetwork
import dev.lowrespalmtree.comet.utils.joinUrls
import dev.lowrespalmtree.comet.utils.toGeminiUri
import dev.lowrespalmtree.comet.utils.resolveLinkUri
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ExperimentalCoroutinesApi
@ -107,13 +107,7 @@ class PageFragment : Fragment(), PageAdapter.Listener {
return
}
var uri = Uri.parse(url)
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()
}
val uri = resolveLinkUri(url, base)
when (uri.scheme) {
"gemini" -> vm.sendGeminiRequest(uri, requireContext())
else -> openUnknownScheme(uri)
@ -152,14 +146,44 @@ class PageFragment : Fragment(), PageAdapter.Listener {
return
when (event) {
is PageViewModel.InputEvent -> {
askForInput(event.prompt, event.uri)
updateState(PageViewModel.State.IDLE)
InputDialog(requireContext(), event.prompt.ifEmpty { "Input required" })
.show(
onOk = { text ->
val newUri = event.uri.buildUpon().query(text).build()
openUrl(newUri.toString(), base = vm.currentUrl)
},
onDismiss = {}
)
}
is PageViewModel.SuccessEvent -> {
vm.currentUrl = event.uri
vm.visitedUrls.add(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 -> {
openUrl(event.uri, base = vm.currentUrl, redirections = event.redirects)
}
@ -170,7 +194,6 @@ class PageFragment : Fragment(), PageAdapter.Listener {
if (!isConnectedToNetwork(requireContext()))
message += "\n\nInternet may be inaccessible…"
alert(message, title = event.short)
updateState(PageViewModel.State.IDLE)
}
}
event.handled = true
@ -184,17 +207,6 @@ class PageFragment : Fragment(), PageAdapter.Listener {
.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) {
try {
startActivity(Intent(Intent.ACTION_VIEW, uri))

View file

@ -1,5 +1,6 @@
package dev.lowrespalmtree.comet
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.util.Log
@ -8,16 +9,19 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.channels.Channel
import kotlinx.coroutines.channels.onSuccess
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.nio.charset.Charset
class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedStateHandle) :
ViewModel() {
class PageViewModel(
@Suppress("unused") private val savedStateHandle: SavedStateHandle
) : ViewModel() {
/** Currently viewed page URL. */
var currentUrl: String = ""
@ -42,14 +46,37 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
/** Lines for the current page. */
private var linesList = ArrayList<Line>()
/** Page state to be reflected on the UI (e.g. loading bar). */
enum class State {
IDLE, CONNECTING, RECEIVING
}
/** Generic event class to notify observers with. The handled flag avoids repeated usage. */
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()
/** The server responded with a success code and *has finished* its response. */
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()
/** The server responded with a failure code or we encountered a local issue. */
data class FailureEvent(
val short: String,
val details: String,
@ -59,7 +86,9 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
/**
* 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
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) {
event.postValue(FailureEvent("Error", message))
state.postValue(State.IDLE)
}
/** Notify observers that user input has been requested. */
private fun handleInputResponse(response: Response, uri: Uri) {
event.postValue(InputEvent(uri, response.meta))
state.postValue(State.IDLE)
}
/** Continue processing a successful response by looking at the provided MIME type. */
@ExperimentalCoroutinesApi
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)
val uriString = uri.toString()
@ -163,9 +212,7 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
lineChannelResult.onSuccess { line ->
if (line is LinkLine) {
// Mark visited links here as we have a access to the history.
val fullUrl =
if (Uri.parse(line.url).isAbsolute) line.url
else joinUrls(uriString, line.url).toString()
val fullUrl = resolveLinkUri(line.url, uriString).toString()
if (History.contains(fullUrl))
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
// and we're already in a coroutine for database access.
History.record(uri.toString(), mainTitle)
event.postValue(SuccessEvent(uri.toString()))
History.record(uriString, mainTitle)
event.postValue(SuccessEvent(uriString))
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) {
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) {
val briefMessage = when (response.code) {
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())
serverMessage = response.meta
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 {

View file

@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.net.Uri
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import java.io.BufferedInputStream
@ -19,6 +20,12 @@ import javax.net.ssl.*
class Request(private val uri: Uri, private val keyManager: KeyManager? = null) {
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 {
Log.d(
TAG,
@ -34,18 +41,18 @@ class Request(private val uri: Uri, private val keyManager: KeyManager? = null)
return socket
}
/** Return a byte array channel carrying the data chunks sent by the server. */
fun proceed(socket: SSLSocket, scope: CoroutineScope): Channel<ByteArray> {
Log.d(TAG, "proceed")
socket.outputStream.write("$uri\r\n".toByteArray())
val channel = Channel<ByteArray>()
scope.launch {
scope.launch(Dispatchers.IO) {
val buffer = ByteArray(1024)
var numRead: Int
socket.inputStream.use { socket_input_stream ->
BufferedInputStream(socket_input_stream).use { bis ->
try {
@Suppress("BlockingMethodInNonBlockingContext") // what u gonna do
while ((bis.read(buffer).also { numRead = it }) >= 0) {
val received = buffer.sliceArray(0 until numRead)
channel.send(received)
@ -63,6 +70,13 @@ class Request(private val uri: Uri, private val keyManager: KeyManager? = null)
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(
private val alias: String,
private val cert: X509Certificate,

View 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"

View file

@ -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

View file

@ -2,6 +2,26 @@ package dev.lowrespalmtree.comet.utils
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
*

View file

@ -47,5 +47,8 @@
<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="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>

View 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()
}
}