Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement MVP Share UI as designed #6

Merged
merged 11 commits into from
Nov 25, 2021
2 changes: 2 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ android {
kotlinOptions {
jvmTarget = '1.8'
useIR = true
freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn'
}
buildFeatures {
compose true
Expand All @@ -59,6 +60,7 @@ dependencies {
implementation 'com.google.android.material:material:1.4.0'
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"

implementation "com.google.dagger:hilt-android:$hilt_version"
Expand Down
31 changes: 18 additions & 13 deletions app/src/main/java/org/onionshare/android/files/FileManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import android.util.Base64.NO_PADDING
import android.util.Base64.URL_SAFE
import android.util.Base64.encodeToString
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.CancellationException
import org.onionshare.android.R
import org.onionshare.android.files.FileManager.State.FilesAdded
import org.onionshare.android.files.FileManager.State.FilesReadyForDownload
Expand All @@ -23,15 +24,13 @@ class FileManager @Inject constructor(
) {

sealed class State {
object NoFiles : State()
open class FilesAdded(val files: List<SendFile>) : State()
class FilesReadyForDownload(files: List<SendFile>, val zip: File) : FilesAdded(files)
}

private val ctx = app.applicationContext

fun addFiles(uris: List<Uri>, state: State): FilesAdded {
val existingFiles = if (state is FilesAdded) state.files else emptyList()
fun addFiles(uris: List<Uri>, existingFiles: List<SendFile>): FilesAdded {
val files = uris.mapNotNull { uri ->
// continue if we already have that file
if (existingFiles.any { it.uri == uri }) return@mapNotNull null
Expand All @@ -42,27 +41,33 @@ class FileManager @Inject constructor(
val sizeHuman = if (size == 0L) ctx.getString(R.string.unknown) else {
formatShortFileSize(ctx, size)
}
SendFile(name, sizeHuman, uri)
SendFile(name, sizeHuman, size, uri, documentFile.type)
}
return FilesAdded(existingFiles + files)
}

fun zipFiles(state: FilesAdded): FilesReadyForDownload {
fun zipFiles(files: List<SendFile>, ensureActive: () -> Unit): FilesReadyForDownload {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than passing a lambda that wraps the coroutine scope's ensureActive() method, I wonder if it might be clearer to pass the coroutine scope and call its ensureActive() method explicitly? I think that would make it more obvious that the lambda is expected to throw CancellationException to break the loop.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is actually how it was before. I had changed it, because I found it a bit strange to hand over coroutine contexts in a function, but maybe that's fine.

What might be better and seems to be possible is using currentCoroutineContext().ensureActive() which can be done when we make this a suspend function.

val zipFileName = encodeToString(Random.nextBytes(32), NO_PADDING or URL_SAFE).trimEnd()
ctx.openFileOutput(zipFileName, MODE_PRIVATE).use { fileOutputStream ->
ZipOutputStream(fileOutputStream).use { zipStream ->
state.files.forEach { file ->
ctx.contentResolver.openInputStream(file.uri)?.use { inputStream ->
zipStream.putNextEntry(ZipEntry(file.basename))
inputStream.copyTo(zipStream)
val zipFile = ctx.getFileStreamPath(zipFileName)
try {
ctx.openFileOutput(zipFileName, MODE_PRIVATE).use { fileOutputStream ->
ZipOutputStream(fileOutputStream).use { zipStream ->
files.forEach { file ->
// check first if we got cancelled before adding another file to the zip
ensureActive()
ctx.contentResolver.openInputStream(file.uri)?.use { inputStream ->
zipStream.putNextEntry(ZipEntry(file.basename))
inputStream.copyTo(zipStream)
}
}
}
}
} catch (e: CancellationException) {
zipFile.delete()
}
val zipFile = ctx.getFileStreamPath(zipFileName)
// TODO we should take better care to clean up old zip files properly
zipFile.deleteOnExit()
return FilesReadyForDownload(state.files, zipFile)
return FilesReadyForDownload(files, zipFile)
}

private fun Uri.getFallBackName(): String? {
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/org/onionshare/android/server/SendPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,16 @@ data class SendFile(
* Used by template.
*/
val size_human: String,
/**
* Used internally to calculate the total file size.
*/
val size: Long,
/**
* Used internally to retrieve the file content.
*/
val uri: Uri,
/**
* Used internally to display different icons.
*/
val mimeType: String?,
)
28 changes: 15 additions & 13 deletions app/src/main/java/org/onionshare/android/server/WebserverManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,26 +30,25 @@ import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.onionshare.android.server.WebserverManager.State.SHOULD_STOP
import org.onionshare.android.server.WebserverManager.State.STARTED
import org.onionshare.android.server.WebserverManager.State.STOPPED
import org.slf4j.LoggerFactory
import java.security.SecureRandom
import javax.inject.Inject

private val LOG = LoggerFactory.getLogger(WebserverManager::class.java)
internal const val PORT: Int = 17638

class WebserverManager @Inject constructor() {

enum class State { STARTING, STARTED, STOPPING, STOPPED }

private val _state = MutableStateFlow(State.STOPPED)
val state: StateFlow<State> = _state
enum class State { STARTED, SHOULD_STOP, STOPPED }

private val secureRandom = SecureRandom()
private var server: ApplicationEngine? = null
private var state = MutableStateFlow(STOPPED)
grote marked this conversation as resolved.
Show resolved Hide resolved

fun onFilesBeingZipped() {
_state.value = State.STARTING
}

fun start(sendPage: SendPage) {
fun start(sendPage: SendPage): StateFlow<State> {
val staticPath = getStaticPath()
val staticPathMap = mapOf("static_url_path" to staticPath)
server = embeddedServer(Netty, PORT, watchPaths = emptyList()) {
Expand All @@ -58,17 +57,18 @@ class WebserverManager @Inject constructor() {
loader(ClasspathLoader().apply { prefix = "assets/templates" })
}
installStatusPages(staticPathMap)
// this method will not return until the continuation in that listener gets resumed
grote marked this conversation as resolved.
Show resolved Hide resolved
addListener()
routing {
defaultRoutes(staticPath)
sendRoutes(sendPage, staticPathMap)
}
}.also { it.start() }
return state
}

fun stop() {
_state.value = State.STOPPING
server?.stop(1_000, 2_000)
server?.stop(500, 1_000)
}

private fun getStaticPath(): String {
Expand All @@ -80,10 +80,10 @@ class WebserverManager @Inject constructor() {

private fun Application.addListener() {
environment.monitor.subscribe(ApplicationStarted) {
_state.value = State.STARTED
state.value = STARTED
}
environment.monitor.subscribe(ApplicationStopped) {
_state.value = State.STOPPED
state.value = STOPPED
}
}

Expand Down Expand Up @@ -124,6 +124,8 @@ class WebserverManager @Inject constructor() {
Attachment.withParameter(FileName, sendPage.fileName).toString()
)
call.respondFile(sendPage.zipFile)
LOG.info("Download complete. Emitting SHOULD_STOP state...")
state.value = SHOULD_STOP
akwizgran marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
166 changes: 146 additions & 20 deletions app/src/main/java/org/onionshare/android/ui/FileList.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,59 +2,185 @@ package org.onionshare.android.ui

import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.net.Uri
import android.text.format.Formatter.formatShortFileSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Card
import androidx.compose.material.ContentAlpha
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Slideshow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.onionshare.android.files.FileManager
import org.onionshare.android.R
import org.onionshare.android.server.SendFile
import org.onionshare.android.ui.theme.OnionshareTheme

@Composable
fun FileList(fileManagerState: State<FileManager.State>) {
val files = when (val state = fileManagerState.value) {
is FileManager.State.FilesAdded -> state.files
else -> error("Wrong state: $state")
}
fun FileList(
modifier: Modifier = Modifier,
state: State<ShareUiState>,
onFileRemove: (SendFile) -> Unit,
onRemoveAll: () -> Unit,
) {
val files = state.value.files
val ctx = LocalContext.current
val totalSize = formatShortFileSize(ctx, state.value.totalSize)
val res = ctx.resources
val text =
res.getQuantityString(R.plurals.share_file_list_summary, files.size, files.size, totalSize)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp)
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Text(text, modifier = Modifier.weight(1f))
if (state.value.allowsModifyingFiles) {
TextButton(onClick = onRemoveAll) {
Text(stringResource(R.string.clear_all))
}
}
}
}
items(files) { file ->
Card {
Row {
Text(file.basename, modifier = Modifier
.padding(all = 16.dp)
.weight(1f))
Text(file.size_human, modifier = Modifier.padding(all = 16.dp))
FileRow(file, state.value.allowsModifyingFiles, onFileRemove)
}
}
}

@Composable
fun FileRow(file: SendFile, editAllowed: Boolean, onFileRemove: (SendFile) -> Unit) {
Row(modifier = Modifier.padding(8.dp)) {
Icon(
imageVector = getIconFromMimeType(file.mimeType),
contentDescription = "test",
tint = MaterialTheme.colors.onSurface,
modifier = Modifier
.size(48.dp)
.alpha(0.54f)
.align(Alignment.CenterVertically)
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
) {
Text(
text = file.basename,
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(all = 2.dp),
)
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(
text = file.size_human,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(all = 2.dp)
)
}
}
if (editAllowed) {
var expanded by remember { mutableStateOf(false) }
IconButton(
onClick = { expanded = true },
modifier = Modifier
.align(Alignment.CenterVertically)
) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = "test",
modifier = Modifier.alpha(0.54f)
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
DropdownMenuItem(
onClick = {
onFileRemove(file)
expanded = false
}
) {
Text(stringResource(R.string.remove))
}
}
}
}
}
}

private fun getIconFromMimeType(mimeType: String?): ImageVector = when {
mimeType == null -> Icons.Filled.InsertDriveFile
mimeType.startsWith("image") -> Icons.Filled.Image
mimeType.startsWith("video") -> Icons.Filled.Slideshow
mimeType.startsWith("audio") -> Icons.Filled.MusicNote
else -> Icons.Filled.InsertDriveFile
}

@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
@Composable
fun FileRowPreview(editAllowed: Boolean = true) {
OnionshareTheme {
Surface(color = MaterialTheme.colors.background) {
FileRow(
SendFile("foo", "1 KiB", 1, Uri.parse("/foo"), null),
editAllowed,
) { }
}
}
}

@Preview(showBackground = true)
@Composable
fun FileRowNoEditPreview() {
FileRowPreview(false)
}

@Preview(showBackground = true)
@Composable
fun FileListPreview() {
OnionshareTheme {
val files = listOf(
SendFile("foo", "1 KiB", Uri.parse("/foo")),
SendFile("bar", "42 MiB", Uri.parse("/bar")),
SendFile("foo", "1 KiB", 1, Uri.parse("/foo"), "image/jpeg"),
SendFile("bar", "42 MiB", 2, Uri.parse("/bar"), "video/mp4"),
SendFile("foo bar", "23 MiB", 3, Uri.parse("/foo/bar"), null),
)
val totalSize = files.sumOf { it.size }
val mutableState = remember {
mutableStateOf(FileManager.State.FilesAdded(files))
mutableStateOf(ShareUiState.FilesAdded(files, totalSize))
}
FileList(mutableState)
FileList(Modifier, mutableState, {}) {}
}
}
Loading