Skip to content

Commit

Permalink
Merge pull request #6 from grote/share-ui
Browse files Browse the repository at this point in the history
Implement MVP Share UI as designed
  • Loading branch information
grote authored Nov 25, 2021
2 parents dc32ff8 + d633469 commit 5535515
Show file tree
Hide file tree
Showing 19 changed files with 1,225 additions and 191 deletions.
2 changes: 2 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ android {
kotlinOptions {
jvmTarget = '1.8'
useIR = true
freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn'
}
buildFeatures {
compose true
Expand All @@ -84,6 +85,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
34 changes: 21 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,9 @@ 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 kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
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 +26,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 +43,34 @@ 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 {
suspend fun zipFiles(files: List<SendFile>): FilesReadyForDownload {
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 {
@Suppress("BlockingMethodInNonBlockingContext")
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
currentCoroutineContext().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?,
)
27 changes: 14 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 val state = MutableStateFlow(STOPPED)

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 @@ -64,11 +63,11 @@ class WebserverManager @Inject constructor() {
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 +79,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 +123,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
}
}
}
169 changes: 149 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,188 @@ 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.foundation.lazy.rememberLazyListState
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)
val scrollState = rememberLazyListState()
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp)
modifier = modifier,
state = scrollState,
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)) {
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Icon(
imageVector = getIconFromMimeType(file.mimeType),
contentDescription = "test",
modifier = Modifier
.size(48.dp)
.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

0 comments on commit 5535515

Please sign in to comment.