diff --git a/app/build.gradle b/app/build.gradle index 978222ae..a47643ec 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -36,6 +36,7 @@ android { kotlinOptions { jvmTarget = '1.8' useIR = true + freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' } buildFeatures { compose true @@ -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" diff --git a/app/src/main/java/org/onionshare/android/files/FileManager.kt b/app/src/main/java/org/onionshare/android/files/FileManager.kt index d6d508ca..91df6a23 100644 --- a/app/src/main/java/org/onionshare/android/files/FileManager.kt +++ b/app/src/main/java/org/onionshare/android/files/FileManager.kt @@ -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 @@ -23,15 +26,13 @@ class FileManager @Inject constructor( ) { sealed class State { - object NoFiles : State() open class FilesAdded(val files: List) : State() class FilesReadyForDownload(files: List, val zip: File) : FilesAdded(files) } private val ctx = app.applicationContext - fun addFiles(uris: List, state: State): FilesAdded { - val existingFiles = if (state is FilesAdded) state.files else emptyList() + fun addFiles(uris: List, existingFiles: List): FilesAdded { val files = uris.mapNotNull { uri -> // continue if we already have that file if (existingFiles.any { it.uri == uri }) return@mapNotNull null @@ -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): 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? { diff --git a/app/src/main/java/org/onionshare/android/server/SendPage.kt b/app/src/main/java/org/onionshare/android/server/SendPage.kt index 217e4068..1dc7be98 100644 --- a/app/src/main/java/org/onionshare/android/server/SendPage.kt +++ b/app/src/main/java/org/onionshare/android/server/SendPage.kt @@ -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?, ) diff --git a/app/src/main/java/org/onionshare/android/server/WebserverManager.kt b/app/src/main/java/org/onionshare/android/server/WebserverManager.kt index 38f753d8..8c982fc5 100644 --- a/app/src/main/java/org/onionshare/android/server/WebserverManager.kt +++ b/app/src/main/java/org/onionshare/android/server/WebserverManager.kt @@ -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 + 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 { val staticPath = getStaticPath() val staticPathMap = mapOf("static_url_path" to staticPath) server = embeddedServer(Netty, PORT, watchPaths = emptyList()) { @@ -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 { @@ -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 } } @@ -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 } } } diff --git a/app/src/main/java/org/onionshare/android/ui/FileList.kt b/app/src/main/java/org/onionshare/android/ui/FileList.kt index 2e87d141..2688f743 100644 --- a/app/src/main/java/org/onionshare/android/ui/FileList.kt +++ b/app/src/main/java/org/onionshare/android/ui/FileList.kt @@ -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) { - 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, + 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, {}) {} } } diff --git a/app/src/main/java/org/onionshare/android/ui/MainActivity.kt b/app/src/main/java/org/onionshare/android/ui/MainActivity.kt index e9856bbf..5b62f55b 100644 --- a/app/src/main/java/org/onionshare/android/ui/MainActivity.kt +++ b/app/src/main/java/org/onionshare/android/ui/MainActivity.kt @@ -1,7 +1,6 @@ package org.onionshare.android.ui import android.content.ActivityNotFoundException -import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.os.Bundle import android.widget.Toast import android.widget.Toast.LENGTH_SHORT @@ -9,39 +8,11 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts.GetMultipleContents import androidx.activity.viewModels -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Button -import androidx.compose.material.ExtendedFloatingActionButton -import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import org.onionshare.android.R -import org.onionshare.android.files.FileManager -import org.onionshare.android.server.WebserverManager -import org.onionshare.android.server.WebserverManager.State.STARTED -import org.onionshare.android.server.WebserverManager.State.STARTING -import org.onionshare.android.server.WebserverManager.State.STOPPED -import org.onionshare.android.server.WebserverManager.State.STOPPING import org.onionshare.android.ui.theme.OnionshareTheme -import org.onionshare.android.ui.theme.PurpleOnionShare @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -52,26 +23,19 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { OnionshareTheme { - // A surface container using the 'background' color from the theme Surface(color = MaterialTheme.colors.background) { - Greeting( - name = "OnionSharer", - webserverState = viewModel.webserverState, - onButtonClicked = this::onButtonClicked, - fileManagerState = viewModel.fileManagerState, + MainUi( + stateFlow = viewModel.shareState, onFabClicked = this::onFabClicked, + onFileRemove = viewModel::removeFile, + onRemoveAll = viewModel::removeAll, + onSheetButtonClicked = viewModel::onSheetButtonClicked, ) } } } } - private fun onButtonClicked(currentState: WebserverManager.State) = when (currentState) { - STOPPED -> viewModel.startServer() - STARTED -> viewModel.stopServer() - else -> error("Illegal click state: $currentState") - } - private val launcher = registerForActivityResult(GetMultipleContents()) { uris -> viewModel.onUrisReceived(uris) } @@ -84,81 +48,3 @@ class MainActivity : ComponentActivity() { } } } - -@Composable -fun Greeting( - name: String, - fileManagerState: StateFlow, - onButtonClicked: (WebserverManager.State) -> Unit, - webserverState: StateFlow, - onFabClicked: () -> Unit, -) { - val state = webserverState.collectAsState() - Scaffold( - floatingActionButton = { - if (state.value == STOPPED) { - val text = "Add files" - ExtendedFloatingActionButton( - onClick = onFabClicked, - icon = { Icon(Icons.Filled.Add, contentDescription = text) }, - text = { Text(text) }, - backgroundColor = PurpleOnionShare, - ) - } - } - ) { - MainContent(name, fileManagerState, onButtonClicked, state) - } -} - -@Composable -fun MainContent( - name: String, - fileManagerState: StateFlow, - onButtonClicked: (WebserverManager.State) -> Unit, - webserverState: State, -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - ) { - val fState = fileManagerState.collectAsState() - if (fState.value == FileManager.State.NoFiles) { - Text( - text = "Hello $name!", - modifier = Modifier.padding(16.dp) - ) - } else { - val wState = webserverState.value - Button( - modifier = Modifier.padding(16.dp), - enabled = wState == STOPPED || wState == STARTED, - onClick = { onButtonClicked(wState) }, - ) { - if (wState == STOPPED || wState == STARTING) Text("Start Webserver") - else if (wState == STARTED || wState == STOPPING) Text("Stop Webserver") - } - FileList(fState) - } - } -} - -@Preview(showBackground = true) -@Composable -fun DefaultPreview() { - OnionshareTheme { - Greeting("Android with a long name for a preview", - MutableStateFlow(FileManager.State.NoFiles), - {}, - MutableStateFlow(STOPPED), - {} - ) - } -} - -@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) -@Composable -fun NightModePreview() = DefaultPreview() diff --git a/app/src/main/java/org/onionshare/android/ui/MainUi.kt b/app/src/main/java/org/onionshare/android/ui/MainUi.kt new file mode 100644 index 00000000..35778031 --- /dev/null +++ b/app/src/main/java/org/onionshare/android/ui/MainUi.kt @@ -0,0 +1,191 @@ +package org.onionshare.android.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.net.Uri +import androidx.annotation.StringRes +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement.Center +import androidx.compose.foundation.layout.Arrangement.Top +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.BottomSheetScaffold +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.rememberBottomSheetScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.onionshare.android.R +import org.onionshare.android.server.SendFile +import org.onionshare.android.ui.theme.OnionshareTheme + +private val bottomSheetPeekHeight = 60.dp + +@Composable +@OptIn(ExperimentalMaterialApi::class) +fun MainUi( + stateFlow: StateFlow, + onFabClicked: () -> Unit, + onFileRemove: (SendFile) -> Unit, + onRemoveAll: () -> Unit, + onSheetButtonClicked: () -> Unit, +) { + val state = stateFlow.collectAsState() + val scaffoldState = rememberBottomSheetScaffoldState() + val offset = getOffsetInDp(scaffoldState.bottomSheetState.offset) + if (state.value == ShareUiState.NoFiles) { + Scaffold( + topBar = { ActionBar(R.string.app_name) }, + floatingActionButton = { Fab(state.value, offset, onFabClicked) }, + ) { + MainContent(stateFlow, onFileRemove, onRemoveAll) + } + LaunchedEffect("hideSheet") { + // This ensures the FAB color can animate back when we transition to NoFiles state + scaffoldState.bottomSheetState.collapse() + } + } else { + LaunchedEffect("showSheet") { + delay(750) + scaffoldState.bottomSheetState.expand() + } + BottomSheetScaffold( + topBar = { ActionBar(R.string.app_name) }, + floatingActionButton = { Fab(state.value, offset, onFabClicked) }, + sheetPeekHeight = bottomSheetPeekHeight, + sheetShape = RoundedCornerShape(16.dp), + scaffoldState = scaffoldState, + sheetContent = { BottomSheet(state.value, onSheetButtonClicked) } + ) { + MainContent(stateFlow, onFileRemove, onRemoveAll) + } + } +} + +@Composable +private fun getOffsetInDp(offset: State): Dp { + val value by remember { offset } + if (value == 0f) return 0.dp + val configuration = LocalConfiguration.current + val screenHeight = with(LocalDensity.current) { configuration.screenHeightDp.dp.toPx() } + return with(LocalDensity.current) { + (screenHeight - value).toDp() + } +} + +@Composable +fun ActionBar(@StringRes res: Int) { + TopAppBar( + backgroundColor = MaterialTheme.colors.primary, + title = { Text(stringResource(res)) }, + ) +} + +@Composable +fun Fab(state: ShareUiState, offset: Dp, onFabClicked: () -> Unit) { + if (state.allowsModifyingFiles) { + val color by animateColorAsState( + targetValue = if (offset <= bottomSheetPeekHeight) { + MaterialTheme.colors.primary + } else { + MaterialTheme.colors.surface + }, + animationSpec = tween(durationMillis = 500, easing = LinearEasing), + ) + FloatingActionButton( + onClick = onFabClicked, + backgroundColor = color, + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = stringResource(R.string.share_files_add), + ) + } + } +} + +@Composable +fun MainContent( + stateFlow: StateFlow, + onFileRemove: (SendFile) -> Unit, + onRemoveAll: () -> Unit, +) { + val state = stateFlow.collectAsState() + Column( + horizontalAlignment = CenterHorizontally, + verticalArrangement = if (state.value is ShareUiState.NoFiles) Center else Top, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + ) { + if (state.value is ShareUiState.NoFiles) { + Image(painterResource(R.drawable.ic_share_empty_state), contentDescription = null) + Text( + text = stringResource(R.string.share_empty_state), + modifier = Modifier.padding(16.dp), + ) + } else { + val modifier = Modifier.padding(bottom = bottomSheetPeekHeight) + FileList(modifier, state, onFileRemove, onRemoveAll) + } + } +} + +@Preview(showBackground = true) +@Composable +fun DefaultPreview() { + val files = listOf( + SendFile("foo", "23 KiB", 1337L, Uri.parse(""), null) + ) + OnionshareTheme { + MainUi( + stateFlow = MutableStateFlow(ShareUiState.FilesAdded(files, 1337L)), + onFabClicked = {}, + onFileRemove = {}, + onRemoveAll = {}, + onSheetButtonClicked = {}, + ) + } +} + +@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) +@Composable +fun NightModePreview() { + OnionshareTheme { + MainUi( + stateFlow = MutableStateFlow(ShareUiState.NoFiles), + onFabClicked = {}, + onFileRemove = {}, + onRemoveAll = {}, + onSheetButtonClicked = {}, + ) + } +} diff --git a/app/src/main/java/org/onionshare/android/ui/MainViewModel.kt b/app/src/main/java/org/onionshare/android/ui/MainViewModel.kt index a7918c50..5d996510 100644 --- a/app/src/main/java/org/onionshare/android/ui/MainViewModel.kt +++ b/app/src/main/java/org/onionshare/android/ui/MainViewModel.kt @@ -4,14 +4,19 @@ import android.app.Application import android.net.Uri import android.text.format.Formatter import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import org.onionshare.android.files.FileManager +import org.onionshare.android.server.PORT +import org.onionshare.android.server.SendFile import org.onionshare.android.server.SendPage import org.onionshare.android.server.WebserverManager import org.slf4j.LoggerFactory @@ -22,19 +27,20 @@ private val LOG = LoggerFactory.getLogger(MainViewModel::class.java) @HiltViewModel class MainViewModel @Inject constructor( private val app: Application, - handle: SavedStateHandle, private val webserverManager: WebserverManager, private val fileManager: FileManager, ) : AndroidViewModel(app) { - private val _fileManagerState = MutableStateFlow(FileManager.State.NoFiles) - val fileManagerState: StateFlow = _fileManagerState - val webserverState = webserverManager.state + private val _shareState = MutableStateFlow(ShareUiState.NoFiles) + val shareState: StateFlow = _shareState + + @Volatile + var startSharingJob: Job? = null override fun onCleared() { super.onCleared() // TODO later, we shouldn't stop the server when closing the activity - stopServer() + stopSharing() } fun onUrisReceived(uris: List) { @@ -42,21 +48,44 @@ class MainViewModel @Inject constructor( // not supporting selecting entire folders with sub-folders viewModelScope.launch(Dispatchers.IO) { - val filesAdded = fileManager.addFiles(uris, fileManagerState.value) - _fileManagerState.value = filesAdded + val filesAdded = fileManager.addFiles(uris, shareState.value.files) + val totalSize = filesAdded.files.sumOf { it.size } + _shareState.value = ShareUiState.FilesAdded(filesAdded.files, totalSize) } } - fun startServer() { - // FIXME this is a mixing of concerns to get the right state in the UI - webserverManager.onFilesBeingZipped() + fun removeFile(file: SendFile) { + val newList = shareState.value.files.filterNot { it == file } + if (newList.isEmpty()) { + _shareState.value = ShareUiState.NoFiles + } else { + val totalSize = newList.sumOf { it.size } + _shareState.value = ShareUiState.FilesAdded(newList, totalSize) + } + } - viewModelScope.launch(Dispatchers.IO) { - val filesAdded = fileManagerState.value as FileManager.State.FilesAdded - val filesReady = fileManager.zipFiles(filesAdded) - _fileManagerState.value = filesReady + fun removeAll() { + _shareState.value = ShareUiState.NoFiles + } + + fun onSheetButtonClicked() { + when (shareState.value) { + is ShareUiState.FilesAdded -> startSharing() + is ShareUiState.Starting -> stopSharing() + is ShareUiState.Sharing -> stopSharing() + is ShareUiState.Complete -> startSharing() + ShareUiState.NoFiles -> error("Sheet button should not be visible with no files") + } + } - LOG.error("$filesReady") + private fun startSharing() { + startSharingJob = viewModelScope.launch(Dispatchers.IO) { + val files = shareState.value.files + // call ensureActive() before any heavy work to ensure we don't continue when cancelled + ensureActive() + _shareState.value = ShareUiState.Starting(files, shareState.value.totalSize) + ensureActive() + val filesReady = fileManager.zipFiles(files) val fileSize = filesReady.zip.length() val sendPage = SendPage( fileName = "download.zip", @@ -66,13 +95,45 @@ class MainViewModel @Inject constructor( ).apply { addFiles(filesReady.files) } - webserverManager.start(sendPage) + ensureActive() + val url = "http://127.0.0.1:$PORT" +// val url = "http://openpravyvc6spbd4flzn4g2iqu4sxzsizbtb5aqec25t76dnoo5w7yd.onion/" + val sharing = ShareUiState.Sharing(files, shareState.value.totalSize, url) + // collecting from StateFlow will only return when coroutine gets cancelled + webserverManager.start(sendPage).collect { onWebserverStateChanged(it, sharing) } + } + } + + private fun onWebserverStateChanged( + state: WebserverManager.State, + sharing: ShareUiState.Sharing, + ) { + when (state) { + WebserverManager.State.STARTED -> _shareState.value = sharing + WebserverManager.State.SHOULD_STOP -> stopSharing(true) + // Stopping again could cause a harmless double stop, + // but ensures state update when webserver stops unexpectedly. + // In practise, we cancel the coroutine of this collector when stopping the first time, + // so calling stopSharing() twice should actually not happen. + WebserverManager.State.STOPPED -> stopSharing() } } - fun stopServer() { + private fun stopSharing(complete: Boolean = false) { viewModelScope.launch(Dispatchers.IO) { + if (startSharingJob?.isActive == true) { + // TODO check if this always works as expected + startSharingJob?.cancelAndJoin() + } + webserverManager.stop() + val files = shareState.value.files + val newState = when { + files.isEmpty() -> ShareUiState.NoFiles + complete -> ShareUiState.Complete(files, files.sumOf { it.size }) + else -> ShareUiState.FilesAdded(files, files.sumOf { it.size }) + } + _shareState.value = newState } } diff --git a/app/src/main/java/org/onionshare/android/ui/ShareBottomSheet.kt b/app/src/main/java/org/onionshare/android/ui/ShareBottomSheet.kt new file mode 100644 index 00000000..176bd956 --- /dev/null +++ b/app/src/main/java/org/onionshare/android/ui/ShareBottomSheet.kt @@ -0,0 +1,242 @@ +package org.onionshare.android.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context.CLIPBOARD_SERVICE +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.widget.Toast +import android.widget.Toast.LENGTH_SHORT +import androidx.annotation.StringRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily.Companion.Monospace +import androidx.compose.ui.text.font.FontStyle.Companion.Italic +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.onionshare.android.R +import org.onionshare.android.ui.theme.IndicatorReady +import org.onionshare.android.ui.theme.IndicatorSharing +import org.onionshare.android.ui.theme.IndicatorStarting +import org.onionshare.android.ui.theme.OnionshareTheme + +private data class BottomSheetUi( + val indicatorIcon: ImageVector = Icons.Filled.Circle, + val indicatorColor: Color, + @StringRes val stateText: Int, + @StringRes val buttonText: Int, +) + +private fun getBottomSheetUi(state: ShareUiState) = when (state) { + is ShareUiState.FilesAdded -> BottomSheetUi( + indicatorColor = IndicatorReady, + stateText = R.string.share_state_ready, + buttonText = R.string.share_button_start, + ) + is ShareUiState.Starting -> BottomSheetUi( + indicatorColor = IndicatorStarting, + stateText = R.string.share_state_starting, + buttonText = R.string.share_button_starting, + ) + is ShareUiState.Sharing -> BottomSheetUi( + indicatorColor = IndicatorSharing, + stateText = R.string.share_state_sharing, + buttonText = R.string.share_button_stop, + ) + is ShareUiState.Complete -> BottomSheetUi( + indicatorIcon = Icons.Filled.CheckCircle, + indicatorColor = IndicatorSharing, + stateText = R.string.share_state_transfer_complete, + buttonText = R.string.share_button_complete, + ) + is ShareUiState.NoFiles -> error("No bottom sheet in empty state.") +} + +@Composable +fun BottomSheet(state: ShareUiState, onSheetButtonClicked: () -> Unit) { + if (state is ShareUiState.NoFiles) return + val sheetUi = getBottomSheetUi(state) + Column { + Image( + imageVector = Icons.Filled.DragHandle, + contentDescription = null, + colorFilter = ColorFilter.tint(Color.Gray), + contentScale = ContentScale.Crop, + modifier = Modifier + .size(24.dp, 14.dp) + .padding(top = 4.dp) + .align(CenterHorizontally), + ) + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(start = 16.dp, bottom = 16.dp), + ) { + Icon( + imageVector = sheetUi.indicatorIcon, + tint = sheetUi.indicatorColor, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Text( + text = stringResource(sheetUi.stateText), + style = MaterialTheme.typography.h5, + modifier = Modifier.padding(start = 16.dp), + ) + } + Divider(thickness = 2.dp) + val colorControlNormal = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + if (state is ShareUiState.Sharing) { + Text( + text = stringResource(R.string.share_onion_intro), + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + ) + Row(modifier = Modifier.padding(16.dp)) { + SelectionContainer( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + ) { + Text(state.onionAddress, fontFamily = Monospace) + } + val clipBoardLabel = stringResource(R.string.clipboard_onion_service_label) + val ctx = LocalContext.current + val clipboard = ctx.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + Button( + onClick = { + val clip = ClipData.newPlainText(clipBoardLabel, state.onionAddress) + clipboard.setPrimaryClip(clip) + Toast.makeText(ctx, R.string.clipboard_onion_service_copied, LENGTH_SHORT) + .show() + }, + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.surface, + contentColor = MaterialTheme.colors.secondary, + ), + border = BorderStroke(1.dp, colorControlNormal), + shape = RoundedCornerShape(32.dp), + modifier = Modifier + .align(CenterVertically) + .size(48.dp) + ) { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = null, + modifier = Modifier.requiredSize(24.dp), + ) + } + } + Divider(thickness = 2.dp) + } + Button( + onClick = onSheetButtonClicked, + colors = if (state is ShareUiState.Sharing) { + ButtonDefaults.buttonColors(contentColor = Color.Red, + backgroundColor = MaterialTheme.colors.background) + } else ButtonDefaults.buttonColors(), + border = if (state is ShareUiState.Sharing) { + BorderStroke(1.dp, colorControlNormal) + } else null, + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + ) { + Text( + text = stringResource(sheetUi.buttonText), + fontStyle = if (state is ShareUiState.Starting) Italic else null, + modifier = Modifier.padding(8.dp) + ) + } + } +} + +@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) +@Composable +fun ShareBottomSheetReadyPreview() { + OnionshareTheme { + Surface(color = MaterialTheme.colors.background) { + BottomSheet( + state = ShareUiState.FilesAdded(emptyList(), 0L), + onSheetButtonClicked = {}, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun ShareBottomSheetStartingPreview() { + OnionshareTheme { + Surface(color = MaterialTheme.colors.background) { + BottomSheet( + state = ShareUiState.Starting(emptyList(), 0L), + onSheetButtonClicked = {}, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun ShareBottomSheetSharingPreview() { + OnionshareTheme { + Surface(color = MaterialTheme.colors.background) { + BottomSheet( + state = ShareUiState.Sharing( + emptyList(), + 0L, + "http://openpravyvc6spbd4flzn4g2iqu4sxzsizbtb5aqec25t76dnoo5w7yd.onion/", + ), + onSheetButtonClicked = {}, + ) + } + } +} + +@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) +@Composable +fun ShareBottomSheetSharingPreviewNight() { + ShareBottomSheetSharingPreview() +} + +@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) +@Composable +fun ShareBottomSheetCompletePreview() { + OnionshareTheme { + Surface(color = MaterialTheme.colors.background) { + BottomSheet( + state = ShareUiState.Complete(emptyList(), 0L), + onSheetButtonClicked = {}, + ) + } + } +} diff --git a/app/src/main/java/org/onionshare/android/ui/ShareUiState.kt b/app/src/main/java/org/onionshare/android/ui/ShareUiState.kt new file mode 100644 index 00000000..1525e664 --- /dev/null +++ b/app/src/main/java/org/onionshare/android/ui/ShareUiState.kt @@ -0,0 +1,36 @@ +package org.onionshare.android.ui + +import org.onionshare.android.server.SendFile + +sealed class ShareUiState(open val files: List, open val totalSize: Long) { + + open val allowsModifyingFiles = true + + object NoFiles : ShareUiState(emptyList(), 0L) + + data class FilesAdded( + override val files: List, + override val totalSize: Long, + ) : ShareUiState(files, totalSize) + + data class Starting( + override val files: List, + override val totalSize: Long, + ) : ShareUiState(files, totalSize) { + override val allowsModifyingFiles = false + } + + data class Sharing( + override val files: List, + override val totalSize: Long, + val onionAddress: String, + ) : ShareUiState(files, totalSize) { + override val allowsModifyingFiles = false + } + + data class Complete( + override val files: List, + override val totalSize: Long, + ) : ShareUiState(files, totalSize) + +} diff --git a/app/src/main/java/org/onionshare/android/ui/theme/Color.kt b/app/src/main/java/org/onionshare/android/ui/theme/Color.kt index 9d518f65..b4d97dd9 100644 --- a/app/src/main/java/org/onionshare/android/ui/theme/Color.kt +++ b/app/src/main/java/org/onionshare/android/ui/theme/Color.kt @@ -2,8 +2,10 @@ package org.onionshare.android.ui.theme import androidx.compose.ui.graphics.Color -val Purple200 = Color(0xFFBB86FC) -val Purple500 = Color(0xFF6200EE) val Purple700 = Color(0xFF3700B3) val PurpleOnionShare = Color(0xFF4E064F) -val Teal200 = Color(0xFF03DAC5) +val Blue = Color(0xFF007AFF) + +val IndicatorReady = Color.Gray +val IndicatorStarting = Color(0xFFEE9A38) +val IndicatorSharing = Color(0xFFA4C954) diff --git a/app/src/main/java/org/onionshare/android/ui/theme/Theme.kt b/app/src/main/java/org/onionshare/android/ui/theme/Theme.kt index 5ffda306..8a02a7e4 100644 --- a/app/src/main/java/org/onionshare/android/ui/theme/Theme.kt +++ b/app/src/main/java/org/onionshare/android/ui/theme/Theme.kt @@ -10,14 +10,14 @@ import androidx.compose.ui.graphics.Color private val DarkColorPalette = darkColors( primary = PurpleOnionShare, primaryVariant = Purple700, - secondary = Teal200, + secondary = Blue, onPrimary = Color.White, ) private val LightColorPalette = lightColors( primary = PurpleOnionShare, primaryVariant = Purple700, - secondary = Teal200, + secondary = Blue, onPrimary = Color.White, /* Other default colors to override diff --git a/app/src/main/res/drawable/ic_share_empty_state.xml b/app/src/main/res/drawable/ic_share_empty_state.xml new file mode 100644 index 00000000..192aa05c --- /dev/null +++ b/app/src/main/res/drawable/ic_share_empty_state.xml @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index bfb39ff3..23ceac64 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd77fdb9..df804c1d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,4 +3,24 @@ Your device does not support adding files unknown + Remove + Nothing here yet. + Clear All + Add files + Ready + Starting… + Sharing + Transfer complete + Start Sharing + Starting… tap to cancel + Stop Sharing + Start Sharing Again + Anyone with this OnionShare address can download your files using the OnionShare app or Tor Browser: + OnionShare address + OnionShare address copied to clipboard. + + + %1$d item, %2$s + %1$d items, %2$s + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2ccd3f3a..35e515b0 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,6 +1,10 @@ - + +