3 changed files with 525 additions and 0 deletions
@ -0,0 +1,21 @@ |
|||||||
|
package com.utun.chat.data |
||||||
|
|
||||||
|
import com.utun.chat.R |
||||||
|
|
||||||
|
/** UI получает ID ресурса, поэтому ошибки переводятся при каждом отображении. */ |
||||||
|
fun fileStatusResource(status: Int): Int = when (status) { |
||||||
|
1 -> R.string.files_error_access |
||||||
|
2 -> R.string.files_error_missing |
||||||
|
3 -> R.string.files_error_path |
||||||
|
4 -> R.string.files_error_range |
||||||
|
5 -> R.string.files_error_exists |
||||||
|
6 -> R.string.files_error_busy |
||||||
|
7 -> R.string.files_error_stale |
||||||
|
8 -> R.string.files_error_space |
||||||
|
10 -> R.string.files_error_packet |
||||||
|
11 -> R.string.files_error_hash |
||||||
|
12 -> R.string.files_cancelled |
||||||
|
13 -> R.string.files_error_timeout |
||||||
|
14 -> R.string.files_error_nonempty |
||||||
|
else -> R.string.files_error_io |
||||||
|
} |
||||||
@ -0,0 +1,296 @@ |
|||||||
|
package com.utun.chat.ui.screens |
||||||
|
|
||||||
|
import android.content.ClipData |
||||||
|
import android.content.Intent |
||||||
|
import android.net.Uri |
||||||
|
import android.text.format.Formatter |
||||||
|
import android.webkit.MimeTypeMap |
||||||
|
import androidx.activity.compose.BackHandler |
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult |
||||||
|
import androidx.activity.result.contract.ActivityResultContracts |
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi |
||||||
|
import androidx.compose.foundation.combinedClickable |
||||||
|
import androidx.compose.foundation.horizontalScroll |
||||||
|
import androidx.compose.foundation.layout.* |
||||||
|
import androidx.compose.foundation.lazy.LazyColumn |
||||||
|
import androidx.compose.foundation.lazy.items |
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState |
||||||
|
import androidx.compose.foundation.rememberScrollState |
||||||
|
import androidx.compose.material.icons.Icons |
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack |
||||||
|
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile |
||||||
|
import androidx.compose.material.icons.filled.* |
||||||
|
import androidx.compose.material3.* |
||||||
|
import androidx.compose.runtime.* |
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.platform.LocalContext |
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner |
||||||
|
import androidx.compose.ui.res.stringResource |
||||||
|
import androidx.compose.ui.text.style.TextOverflow |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import androidx.core.content.FileProvider |
||||||
|
import androidx.lifecycle.Lifecycle |
||||||
|
import androidx.lifecycle.compose.LifecycleStartEffect |
||||||
|
import com.utun.chat.R |
||||||
|
import com.utun.chat.data.* |
||||||
|
import com.utun.chat.viewmodel.MemberFilesViewModel |
||||||
|
import java.text.DateFormat |
||||||
|
import java.util.Date |
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) |
||||||
|
@Composable |
||||||
|
fun MemberFilesScreen(channel: Channel, viewModel: MemberFilesViewModel, onBack: () -> Unit) { |
||||||
|
val state by viewModel.state.collectAsState() |
||||||
|
val operation by viewModel.operation.collectAsState() |
||||||
|
val conflict by viewModel.conflict.collectAsState() |
||||||
|
val export by viewModel.export.collectAsState() |
||||||
|
val context = LocalContext.current |
||||||
|
val lifecycle by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState() |
||||||
|
val location = state.location |
||||||
|
val own = location?.owner != 0L && location?.owner == NativeLib.getMyNodeId() |
||||||
|
val service = location?.let { MemberFilesProtocol.servicePath(it.path) } == true |
||||||
|
val server = state.servers.find { it.nodeId == location?.server } |
||||||
|
val available = server != null && NativeLib.isReady() |
||||||
|
val busy = operation?.active == true || state.mutating |
||||||
|
var serverMenu by remember { mutableStateOf(false) } |
||||||
|
var addMenu by remember { mutableStateOf(false) } |
||||||
|
var selected by remember { mutableStateOf<MemberFilesProtocol.Entry?>(null) } |
||||||
|
var editing by remember { mutableStateOf<MemberFilesProtocol.Entry?>(null) } |
||||||
|
var newFolder by rememberSaveable { mutableStateOf(false) } |
||||||
|
var deleteEntry by remember { mutableStateOf<MemberFilesProtocol.Entry?>(null) } |
||||||
|
var properties by remember { mutableStateOf<MemberFilesProtocol.Entry?>(null) } |
||||||
|
var editName by rememberSaveable { mutableStateOf("") } |
||||||
|
var externalFailed by rememberSaveable { mutableStateOf(false) } |
||||||
|
var ready by remember { mutableStateOf(viewModel.readyExport) } |
||||||
|
val list = key(location) { rememberLazyListState() } |
||||||
|
fun size(bytes: Long) = Formatter.formatShortFileSize(context, bytes) |
||||||
|
fun date(seconds: Long): String = if (seconds == 0L || seconds > Long.MAX_VALUE / 1000) "—" else |
||||||
|
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT, context.resources.configuration.locales[0]) |
||||||
|
.format(Date(seconds * 1000)) |
||||||
|
fun name(owner: Long): String = state.members.find { it.nodeId == owner }?.name?.takeIf { it.isNotEmpty() } |
||||||
|
?: AvatarProtocol.hex(owner) |
||||||
|
fun back() { if (!viewModel.up()) onBack() } |
||||||
|
|
||||||
|
LifecycleStartEffect(Unit) { viewModel.setVisible(true); onStopOrDispose { viewModel.setVisible(false) } } |
||||||
|
LaunchedEffect(location) { selected = null; editing = null; newFolder = false; deleteEntry = null; properties = null } |
||||||
|
BackHandler { when { |
||||||
|
selected != null -> selected = null |
||||||
|
serverMenu -> serverMenu = false |
||||||
|
addMenu -> addMenu = false |
||||||
|
newFolder || editing != null -> { newFolder = false; editing = null } |
||||||
|
deleteEntry != null -> deleteEntry = null |
||||||
|
properties != null -> properties = null |
||||||
|
else -> back() |
||||||
|
} } |
||||||
|
|
||||||
|
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> |
||||||
|
val target = viewModel.pickerLocation; viewModel.pickerLocation = null |
||||||
|
if (target != null && uris.isNotEmpty()) viewModel.repository.upload(target, uris) |
||||||
|
} |
||||||
|
val saver = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { uri -> |
||||||
|
val prepared = viewModel.pendingSave; viewModel.pendingSave = null |
||||||
|
if (prepared != null && uri != null) viewModel.repository.save(prepared, uri) |
||||||
|
} |
||||||
|
fun deliver(prepared: FileExport, action: Int = prepared.action) { |
||||||
|
viewModel.readyExport = prepared; ready = prepared |
||||||
|
viewModel.repository.consumeExport(prepared.token) |
||||||
|
try { |
||||||
|
if (action == MemberFilesRepository.SAVE) { viewModel.pendingSave = prepared; saver.launch(prepared.name) } |
||||||
|
else { |
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.memberfiles", prepared.file) |
||||||
|
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(prepared.name.substringAfterLast('.', "").lowercase()) |
||||||
|
?: "application/octet-stream" |
||||||
|
val intent = Intent(if (action == MemberFilesRepository.SHARE) Intent.ACTION_SEND else Intent.ACTION_VIEW).apply { |
||||||
|
if (action == MemberFilesRepository.SHARE) { type = mime; putExtra(Intent.EXTRA_STREAM, uri) } |
||||||
|
else setDataAndType(uri, mime) |
||||||
|
clipData = ClipData.newRawUri(prepared.name, uri) |
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) |
||||||
|
} |
||||||
|
context.startActivity(Intent.createChooser(intent, null)) |
||||||
|
} |
||||||
|
LogManager.addLog("INFO", "Files", "export dispatched token=${prepared.token} action=$action file=${prepared.file}") |
||||||
|
} catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "Files", "export failed token=${prepared.token} action=$action: $e") |
||||||
|
externalFailed = true |
||||||
|
} |
||||||
|
} |
||||||
|
LaunchedEffect(export, lifecycle, location) { |
||||||
|
val prepared = export |
||||||
|
if (prepared != null && lifecycle.isAtLeast(Lifecycle.State.RESUMED) && prepared.location == location) deliver(prepared) |
||||||
|
} |
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().navigationBarsPadding()) { |
||||||
|
TopAppBar(title = { Column { |
||||||
|
Text(stringResource(R.string.files_title)) |
||||||
|
Text(channel.name, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) |
||||||
|
} }, navigationIcon = { |
||||||
|
IconButton(onClick = ::back) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) } |
||||||
|
}, actions = { |
||||||
|
IconButton(onClick = viewModel::refresh, enabled = available && !state.loading) { |
||||||
|
Icon(Icons.Default.Refresh, stringResource(R.string.files_refresh)) |
||||||
|
} |
||||||
|
}) |
||||||
|
TextButton(onClick = { serverMenu = true }, modifier = Modifier.fillMaxWidth(), enabled = state.servers.isNotEmpty()) { |
||||||
|
Icon(Icons.Default.Storage, null); Spacer(Modifier.width(8.dp)) |
||||||
|
Text(stringResource(R.string.files_server, server?.name?.takeIf { it.isNotEmpty() } ?: stringResource(R.string.files_choose_server)), |
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) |
||||||
|
Icon(Icons.Default.ArrowDropDown, null) |
||||||
|
} |
||||||
|
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) { |
||||||
|
TextButton(onClick = { viewModel.selectOwner(0) }) { Text(stringResource(R.string.files_members)) } |
||||||
|
if (location != null && location.owner != 0L) { |
||||||
|
Text("/") |
||||||
|
TextButton(onClick = { viewModel.breadcrumb("") }) { Text(name(location.owner)) } |
||||||
|
var path = "" |
||||||
|
location.path.split('/').filter { it.isNotEmpty() }.forEach { component -> |
||||||
|
path = if (path.isEmpty()) component else "$path/$component" |
||||||
|
val target = path |
||||||
|
Text("/"); TextButton(onClick = { viewModel.breadcrumb(target) }) { Text(component) } |
||||||
|
} |
||||||
|
} |
||||||
|
if (!own) TextButton(onClick = { viewModel.selectOwner(NativeLib.getMyNodeId()) }, enabled = NativeLib.isReady()) { |
||||||
|
Text(stringResource(R.string.files_mine)) |
||||||
|
} |
||||||
|
} |
||||||
|
if (state.loading || state.mutating) LinearProgressIndicator(Modifier.fillMaxWidth()) |
||||||
|
state.status?.let { status -> Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { |
||||||
|
Text(stringResource(fileStatusResource(status)), color = MaterialTheme.colorScheme.error, modifier = Modifier.weight(1f)) |
||||||
|
TextButton(onClick = viewModel::refresh) { Text(stringResource(R.string.retry)) } |
||||||
|
} } |
||||||
|
Box(Modifier.weight(1f).fillMaxWidth()) { |
||||||
|
when { |
||||||
|
state.servers.isEmpty() -> Text(stringResource(R.string.files_no_server), Modifier.align(Alignment.Center).padding(24.dp)) |
||||||
|
server == null -> Text(stringResource(R.string.files_server_missing), Modifier.align(Alignment.Center).padding(24.dp)) |
||||||
|
state.entries.isEmpty() -> Text(stringResource(if (state.loading) R.string.files_loading else R.string.files_empty), |
||||||
|
Modifier.align(Alignment.Center).padding(24.dp)) |
||||||
|
else -> LazyColumn(state = list, contentPadding = PaddingValues(bottom = if (own && !service) 88.dp else 12.dp)) { |
||||||
|
items(state.entries, key = { it.name }) { entry -> |
||||||
|
val directory = entry.type == MemberFilesProtocol.DIRECTORY |
||||||
|
val root = location?.owner == 0L |
||||||
|
val label = if (root) entry.name.toULongOrNull()?.toLong()?.let { name(it) } ?: entry.name else entry.name |
||||||
|
ListItem(headlineContent = { Text(label, maxLines = 2, overflow = TextOverflow.Ellipsis) }, |
||||||
|
supportingContent = { |
||||||
|
Text(if (directory) stringResource(if (!root && MemberFilesProtocol.servicePath(entry.name) && location?.path == "") |
||||||
|
R.string.files_service_directory else R.string.files_folder_type) else "${size(entry.size)} · ${date(entry.mtime)}") |
||||||
|
}, leadingContent = { Icon(if (directory) Icons.Default.Folder else Icons.AutoMirrored.Filled.InsertDriveFile, null) }, |
||||||
|
trailingContent = { |
||||||
|
if (!root) IconButton(onClick = { selected = entry }) { Icon(Icons.Default.MoreVert, stringResource(R.string.options)) } |
||||||
|
}, modifier = Modifier.combinedClickable(onClick = { |
||||||
|
if (directory) viewModel.enter(entry) |
||||||
|
else if (!busy && available && location != null) viewModel.repository.download(location, entry, MemberFilesRepository.OPEN) |
||||||
|
}, onLongClick = { if (!root) selected = entry })) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
if (own && !service && available) FloatingActionButton(onClick = { addMenu = true }, |
||||||
|
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), containerColor = MaterialTheme.colorScheme.primaryContainer) { |
||||||
|
Icon(Icons.Default.Add, stringResource(R.string.files_add)) |
||||||
|
} |
||||||
|
} |
||||||
|
if (service || location?.owner != 0L && !own) Text(stringResource(if (service) R.string.files_service_directory else R.string.files_read_only), |
||||||
|
style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) |
||||||
|
state.quota?.let { q -> |
||||||
|
Text(stringResource(R.string.files_quota, size(q.used), size(q.limit)), style = MaterialTheme.typography.bodySmall, |
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) |
||||||
|
if (q.reserved > 0) Text(stringResource(R.string.files_quota_reserved, size(q.reserved)), |
||||||
|
style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp)) |
||||||
|
} |
||||||
|
operation?.let { op -> Card(Modifier.fillMaxWidth().padding(8.dp)) { |
||||||
|
Column(Modifier.padding(12.dp)) { |
||||||
|
Text(op.name.ifEmpty { stringResource(R.string.files_title) }, maxLines = 1, overflow = TextOverflow.Ellipsis) |
||||||
|
Text(stringResource(op.stage) + if (op.count > 1) " · " + stringResource(R.string.files_batch, op.index, op.count) else "") |
||||||
|
if (op.upload) Text(stringResource(R.string.files_summary, op.uploaded, op.skipped)) |
||||||
|
op.status?.let { Text(stringResource(fileStatusResource(it)), color = MaterialTheme.colorScheme.error) } |
||||||
|
if (op.active) { |
||||||
|
if (op.total > 0) { |
||||||
|
LinearProgressIndicator(progress = { (op.done.toDouble() / op.total).toFloat() }, modifier = Modifier.fillMaxWidth()) |
||||||
|
Text(stringResource(R.string.files_bytes, size(op.done), size(op.total))) |
||||||
|
} else LinearProgressIndicator(Modifier.fillMaxWidth()) |
||||||
|
TextButton(onClick = viewModel.repository::cancel) { Text(stringResource(R.string.cancel)) } |
||||||
|
} else Row { |
||||||
|
if (op.retryable && (op.status != null || op.cancelled) && op.status != MemberFilesProtocol.STALE) |
||||||
|
TextButton(onClick = viewModel.repository::retry) { Text(stringResource(R.string.retry)) } |
||||||
|
TextButton(onClick = viewModel.repository::dismiss) { Text(stringResource(R.string.close)) } |
||||||
|
} |
||||||
|
} |
||||||
|
} } |
||||||
|
val prepared = export ?: ready |
||||||
|
if (prepared != null && !busy) Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { |
||||||
|
Text(stringResource(R.string.files_ready, prepared.name), modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) |
||||||
|
TextButton(onClick = { deliver(prepared, MemberFilesRepository.SAVE) }) { Text(stringResource(R.string.save)) } |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (serverMenu) ModalBottomSheet(onDismissRequest = { serverMenu = false }) { |
||||||
|
Text(stringResource(R.string.files_choose_server), style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(16.dp)) |
||||||
|
LazyColumn { items(state.servers, key = { it.nodeId }) { storage -> |
||||||
|
ListItem(headlineContent = { Text(storage.name.ifEmpty { AvatarProtocol.hex(storage.nodeId) }) }, |
||||||
|
leadingContent = { Icon(Icons.Default.Storage, null) }, trailingContent = { |
||||||
|
if (storage.nodeId == location?.server) Icon(Icons.Default.Check, null) |
||||||
|
}, modifier = Modifier.combinedClickable(onClick = { serverMenu = false; viewModel.selectServer(storage.nodeId) })) |
||||||
|
} } |
||||||
|
} |
||||||
|
if (addMenu) ModalBottomSheet(onDismissRequest = { addMenu = false }) { |
||||||
|
TextButton(onClick = { addMenu = false; viewModel.pickerLocation = location; picker.launch(arrayOf("*/*")) }, |
||||||
|
enabled = !busy, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.files_upload)) } |
||||||
|
TextButton(onClick = { addMenu = false; editName = ""; newFolder = true }, enabled = !busy, |
||||||
|
modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.files_folder)) } |
||||||
|
} |
||||||
|
selected?.let { entry -> ModalBottomSheet(onDismissRequest = { selected = null }) { |
||||||
|
Text(entry.name, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(16.dp)) |
||||||
|
if (entry.type == MemberFilesProtocol.FILE) listOf(MemberFilesRepository.OPEN to R.string.files_open, |
||||||
|
MemberFilesRepository.SAVE to R.string.files_save, MemberFilesRepository.SHARE to R.string.files_share).forEach { (action, label) -> |
||||||
|
TextButton(onClick = { selected = null; location?.let { viewModel.repository.download(it, entry, action) } }, |
||||||
|
enabled = !busy && available, modifier = Modifier.fillMaxWidth()) { Text(stringResource(label)) } |
||||||
|
} |
||||||
|
TextButton(onClick = { selected = null; properties = entry }, modifier = Modifier.fillMaxWidth()) { |
||||||
|
Text(stringResource(R.string.files_properties)) |
||||||
|
} |
||||||
|
val editable = own && !service && (location?.path?.isNotEmpty() == true || !MemberFilesProtocol.servicePath(entry.name)) |
||||||
|
if (editable) { |
||||||
|
TextButton(onClick = { selected = null; editing = entry; editName = entry.name }, enabled = !busy && available, |
||||||
|
modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.files_rename)) } |
||||||
|
TextButton(onClick = { selected = null; deleteEntry = entry }, enabled = !busy && available, |
||||||
|
modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) } |
||||||
|
} |
||||||
|
} } |
||||||
|
if (newFolder || editing != null) AlertDialog(onDismissRequest = { newFolder = false; editing = null }, |
||||||
|
title = { Text(stringResource(if (newFolder) R.string.files_folder else R.string.files_rename)) }, text = { |
||||||
|
val valid = location != null && runCatching { MemberFilesProtocol.child(location.path, editName) }.isSuccess && |
||||||
|
!MemberFilesProtocol.servicePath(if (location.path.isEmpty()) editName else "${location.path}/$editName") |
||||||
|
OutlinedTextField(value = editName, onValueChange = { editName = it }, label = { Text(stringResource(R.string.files_name)) }, |
||||||
|
singleLine = true, isError = editName.isNotEmpty() && !valid) |
||||||
|
}, confirmButton = { |
||||||
|
val valid = location != null && runCatching { MemberFilesProtocol.child(location.path, editName) }.isSuccess && |
||||||
|
!MemberFilesProtocol.servicePath(if (location.path.isEmpty()) editName else "${location.path}/$editName") |
||||||
|
TextButton(onClick = { |
||||||
|
viewModel.mutate(if (newFolder) MemberFilesProtocol.MKDIR else MemberFilesProtocol.RENAME, editName, editing) |
||||||
|
newFolder = false; editing = null |
||||||
|
}, enabled = valid && !busy) { Text(stringResource(R.string.save)) } |
||||||
|
}, dismissButton = { TextButton(onClick = { newFolder = false; editing = null }) { Text(stringResource(R.string.cancel)) } }) |
||||||
|
deleteEntry?.let { entry -> AlertDialog(onDismissRequest = { deleteEntry = null }, |
||||||
|
text = { Text(stringResource(R.string.files_delete_confirm, entry.name)) }, confirmButton = { |
||||||
|
TextButton(onClick = { deleteEntry = null; viewModel.mutate(MemberFilesProtocol.REMOVE, entry = entry) }) { |
||||||
|
Text(stringResource(R.string.delete)) |
||||||
|
} |
||||||
|
}, dismissButton = { TextButton(onClick = { deleteEntry = null }) { Text(stringResource(R.string.cancel)) } }) } |
||||||
|
conflict?.let { item -> AlertDialog(onDismissRequest = { viewModel.repository.decideReplace(item.token, null) }, |
||||||
|
title = { Text(stringResource(R.string.files_replace_title)) }, text = { Text(stringResource(R.string.files_replace_confirm, item.name)) }, |
||||||
|
confirmButton = { TextButton(onClick = { viewModel.repository.decideReplace(item.token, true) }) { Text(stringResource(R.string.files_replace)) } }, |
||||||
|
dismissButton = { Row { |
||||||
|
TextButton(onClick = { viewModel.repository.decideReplace(item.token, false) }) { Text(stringResource(R.string.files_skip)) } |
||||||
|
TextButton(onClick = { viewModel.repository.decideReplace(item.token, null) }) { Text(stringResource(R.string.cancel)) } |
||||||
|
} }) } |
||||||
|
properties?.let { entry -> AlertDialog(onDismissRequest = { properties = null }, |
||||||
|
title = { Text(stringResource(R.string.files_properties)) }, text = { |
||||||
|
Text(stringResource(R.string.files_property_values, entry.name, |
||||||
|
if (entry.type == MemberFilesProtocol.FILE) size(entry.size) else stringResource(R.string.files_folder_type), |
||||||
|
date(entry.mtime), location?.owner?.let { name(it) } ?: "")) |
||||||
|
}, confirmButton = { TextButton(onClick = { properties = null }) { Text(stringResource(R.string.close)) } }) } |
||||||
|
if (externalFailed) AlertDialog(onDismissRequest = { externalFailed = false }, |
||||||
|
text = { Text(stringResource(R.string.files_external_failed)) }, confirmButton = { |
||||||
|
TextButton(onClick = { externalFailed = false }) { Text(stringResource(R.string.close)) } |
||||||
|
}) |
||||||
|
} |
||||||
@ -0,0 +1,208 @@ |
|||||||
|
package com.utun.chat.viewmodel |
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle |
||||||
|
import androidx.lifecycle.ViewModel |
||||||
|
import androidx.lifecycle.viewModelScope |
||||||
|
import androidx.lifecycle.viewmodel.initializer |
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory |
||||||
|
import androidx.lifecycle.createSavedStateHandle |
||||||
|
import com.utun.chat.ChatApplication |
||||||
|
import com.utun.chat.data.* |
||||||
|
import kotlinx.coroutines.* |
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow |
||||||
|
import kotlinx.coroutines.flow.StateFlow |
||||||
|
import kotlinx.coroutines.flow.collect |
||||||
|
|
||||||
|
data class FilesBrowserState(val location: FileLocation? = null, val members: List<ChatMember> = emptyList(), |
||||||
|
val servers: List<ChatMember> = emptyList(), val entries: List<MemberFilesProtocol.Entry> = emptyList(), |
||||||
|
val loading: Boolean = false, val mutating: Boolean = false, val status: Int? = null, |
||||||
|
val quota: MemberFilesProtocol.Quota? = null) |
||||||
|
|
||||||
|
/** Навигация живёт в SavedStateHandle, передачи — в репозитории приложения. Context не хранится. */ |
||||||
|
class MemberFilesViewModel(private val saved: SavedStateHandle) : ViewModel() { |
||||||
|
companion object { val Factory = viewModelFactory { initializer { MemberFilesViewModel(createSavedStateHandle()) } } } |
||||||
|
val repository = ChatApplication.instance.memberFiles |
||||||
|
private val avatars = ChatApplication.instance.avatars |
||||||
|
private val _state = MutableStateFlow(FilesBrowserState()) |
||||||
|
val state: StateFlow<FilesBrowserState> = _state |
||||||
|
private var load: Job? = null |
||||||
|
private var quota: Job? = null |
||||||
|
private var visible = false |
||||||
|
var pendingSave: FileExport? = null |
||||||
|
var pickerLocation: FileLocation? = null |
||||||
|
var readyExport: FileExport? = null |
||||||
|
val operation = repository.operation |
||||||
|
val conflict = repository.conflict |
||||||
|
val export = repository.export |
||||||
|
|
||||||
|
init { |
||||||
|
viewModelScope.launch { |
||||||
|
avatars.members.collect { all -> |
||||||
|
val location = _state.value.location ?: return@collect |
||||||
|
val group = location.group.toULong().toString() |
||||||
|
val members = all.filterKeys { it.group == group }.values.filter { !it.isDeleted } |
||||||
|
.sortedWith(compareByDescending<ChatMember> { it.isSelf }.thenBy { it.name.lowercase() }) |
||||||
|
val servers = members.filter { it.storageRole } |
||||||
|
val wasAvailable = _state.value.servers.any { it.nodeId == location.server } |
||||||
|
_state.value = _state.value.copy(members = members, servers = servers) |
||||||
|
if (location.server == 0L && servers.isNotEmpty()) selectServer(servers.first().nodeId) |
||||||
|
else if (!wasAvailable && servers.any { it.nodeId == location.server } && visible) refresh() |
||||||
|
else if (location.server != 0L && servers.none { it.nodeId == location.server }) { |
||||||
|
load?.cancel(); quota?.cancel() |
||||||
|
_state.value = _state.value.copy(entries = emptyList(), quota = null, loading = false, status = 1) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
viewModelScope.launch { |
||||||
|
repository.revision.collect { |
||||||
|
val op = repository.operation.value |
||||||
|
if (visible && op?.location == _state.value.location) refresh() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun open(group: String, owner: Long) { |
||||||
|
val gid = AvatarProtocol.groupId(group) |
||||||
|
if (gid == 0L) { LogManager.addLog("ERROR", "Files", "Invalid channel=$group"); return } |
||||||
|
val oldGroup = saved.get<String>("group") |
||||||
|
val savedOwner = saved.get<Long>("owner") |
||||||
|
val location = if (oldGroup == group && savedOwner == owner && _state.value.location == null) |
||||||
|
FileLocation(gid, saved.get<Long>("server") ?: 0, owner, saved.get<String>("path") ?: "") |
||||||
|
else FileLocation(gid, saved.get<Long>("server:$group") ?: 0, owner, "") |
||||||
|
navigate(location) |
||||||
|
updateMembers(group) |
||||||
|
NativeLib.requestMemberList(group) |
||||||
|
} |
||||||
|
|
||||||
|
fun openLocation(location: FileLocation) { |
||||||
|
navigate(location) |
||||||
|
val group = location.group.toULong().toString() |
||||||
|
updateMembers(group) |
||||||
|
NativeLib.requestMemberList(group) |
||||||
|
} |
||||||
|
|
||||||
|
private fun updateMembers(group: String) { |
||||||
|
val members = avatars.members.value.filterKeys { it.group == group }.values.filter { !it.isDeleted } |
||||||
|
.sortedWith(compareByDescending<ChatMember> { it.isSelf }.thenBy { it.name.lowercase() }) |
||||||
|
val servers = members.filter { it.storageRole } |
||||||
|
_state.value = _state.value.copy(members = members, servers = servers) |
||||||
|
if (_state.value.location?.server == 0L && servers.isNotEmpty()) selectServer(servers.first().nodeId) |
||||||
|
else if (visible) refresh() |
||||||
|
} |
||||||
|
|
||||||
|
fun setVisible(value: Boolean) { |
||||||
|
visible = value |
||||||
|
if (value) refresh() else { load?.cancel(); quota?.cancel(); _state.value = _state.value.copy(loading = false) } |
||||||
|
} |
||||||
|
|
||||||
|
private fun navigate(location: FileLocation) { |
||||||
|
require(MemberFilesProtocol.validPath(location.path) && (location.owner != 0L || location.path.isEmpty())) |
||||||
|
load?.cancel(); quota?.cancel() |
||||||
|
saved["group"] = location.group.toULong().toString(); saved["server"] = location.server |
||||||
|
saved["owner"] = location.owner; saved["path"] = location.path |
||||||
|
_state.value = _state.value.copy(location = location, entries = emptyList(), quota = null, status = null) |
||||||
|
LogManager.addLog("INFO", "Files", "navigate location=$location") |
||||||
|
if (visible) refresh() |
||||||
|
} |
||||||
|
|
||||||
|
fun selectServer(server: Long) { |
||||||
|
val location = _state.value.location ?: return |
||||||
|
if (_state.value.servers.none { it.nodeId == server }) return |
||||||
|
saved["server:${location.group.toULong()}"] = server |
||||||
|
navigate(location.copy(server = server)) |
||||||
|
} |
||||||
|
fun selectOwner(owner: Long) { _state.value.location?.let { navigate(it.copy(owner = owner, path = "")) } } |
||||||
|
fun enter(entry: MemberFilesProtocol.Entry) { |
||||||
|
val location = _state.value.location ?: return |
||||||
|
if (location.owner == 0L) { |
||||||
|
val owner = entry.name.toULongOrNull()?.toLong() |
||||||
|
if (owner == null || owner == 0L) { LogManager.addLog("ERROR", "Files", "Invalid owner entry=${entry.name}"); return } |
||||||
|
selectOwner(owner) |
||||||
|
} else navigate(location.copy(path = MemberFilesProtocol.child(location.path, entry.name))) |
||||||
|
} |
||||||
|
fun up(): Boolean { |
||||||
|
val location = _state.value.location ?: return false |
||||||
|
if (location.path.isNotEmpty()) navigate(location.copy(path = location.path.substringBeforeLast('/', ""))) |
||||||
|
else if (location.owner != 0L) selectOwner(0) |
||||||
|
else return false |
||||||
|
return true |
||||||
|
} |
||||||
|
fun breadcrumb(path: String) { _state.value.location?.let { navigate(it.copy(path = path)) } } |
||||||
|
|
||||||
|
fun refresh() { |
||||||
|
load?.cancel(); quota?.cancel() |
||||||
|
val location = _state.value.location ?: return |
||||||
|
if (!visible || location.server == 0L || _state.value.servers.none { it.nodeId == location.server }) return |
||||||
|
_state.value = _state.value.copy(loading = true, status = null) |
||||||
|
load = viewModelScope.launch { |
||||||
|
try { |
||||||
|
var staleRetries = 0 |
||||||
|
var busyRetries = 0 |
||||||
|
var cursor = 0L |
||||||
|
var version = ByteArray(16) |
||||||
|
val entries = linkedMapOf<String, MemberFilesProtocol.Entry>() |
||||||
|
while (true) { |
||||||
|
val reply = repository.request(location, MemberFilesProtocol.LIST, cursor = cursor, version = version) |
||||||
|
if (reply.status == MemberFilesProtocol.BUSY && busyRetries++ < 30) { delay(1000); continue } |
||||||
|
if (reply.status == MemberFilesProtocol.STALE && staleRetries++ < 1) { |
||||||
|
entries.clear(); cursor = 0; version = ByteArray(16); continue |
||||||
|
} |
||||||
|
repository.check(reply) |
||||||
|
require(cursor == 0L || version.contentEquals(reply.version)) { "directory snapshot changed without STALE" } |
||||||
|
val page = MemberFilesProtocol.entries(reply.payload) |
||||||
|
require(reply.cursor == cursor + page.size) { "invalid directory cursor" } |
||||||
|
page.forEach { require(entries.put(it.name, it) == null) { "duplicate directory entry=${it.name}" } } |
||||||
|
if (!reply.more) break |
||||||
|
require(page.isNotEmpty() && reply.version.any { it != 0.toByte() }) { "directory page made no progress" } |
||||||
|
cursor = reply.cursor; version = reply.version |
||||||
|
} |
||||||
|
if (_state.value.location == location) _state.value = _state.value.copy(loading = false, |
||||||
|
entries = entries.values.sortedWith(compareBy<MemberFilesProtocol.Entry> { it.type != MemberFilesProtocol.DIRECTORY } |
||||||
|
.thenBy { it.name.lowercase() })) |
||||||
|
LogManager.addLog("DEBUG", "Files", "directory ready location=$location entries=${entries.size}") |
||||||
|
} catch (e: CancellationException) { throw e } |
||||||
|
catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "Files", "directory failed location=$location: $e") |
||||||
|
if (_state.value.location == location) _state.value = _state.value.copy(loading = false, entries = emptyList(), |
||||||
|
status = (e as? FileStatusException)?.status ?: 9) |
||||||
|
} |
||||||
|
} |
||||||
|
if (location.owner != 0L) quota = viewModelScope.launch { |
||||||
|
try { |
||||||
|
val base = location.copy(path = "") |
||||||
|
var reply = repository.request(base, MemberFilesProtocol.QUOTA) |
||||||
|
var retries = 0 |
||||||
|
while (reply.status == MemberFilesProtocol.BUSY && retries++ < 30) { |
||||||
|
delay(1000); reply = repository.request(base, MemberFilesProtocol.QUOTA) |
||||||
|
} |
||||||
|
val result = MemberFilesProtocol.quota(repository.check(reply).payload) |
||||||
|
if (_state.value.location == location) _state.value = _state.value.copy(quota = result) |
||||||
|
} catch (e: CancellationException) { throw e } |
||||||
|
catch (e: Exception) { LogManager.addLog("WARN", "Files", "quota failed location=$location: $e") } |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun mutate(command: Int, name: String = "", entry: MemberFilesProtocol.Entry? = null) { |
||||||
|
val location = _state.value.location ?: return |
||||||
|
if (_state.value.mutating || operation.value?.active == true) return |
||||||
|
viewModelScope.launch { |
||||||
|
_state.value = _state.value.copy(mutating = true, status = null) |
||||||
|
try { |
||||||
|
require(location.owner == NativeLib.getMyNodeId()) { "not own directory" } |
||||||
|
val path = if (command == MemberFilesProtocol.MKDIR) MemberFilesProtocol.child(location.path, name) |
||||||
|
else MemberFilesProtocol.child(location.path, requireNotNull(entry).name) |
||||||
|
val target = if (command == MemberFilesProtocol.RENAME) MemberFilesProtocol.child(location.path, name) else "" |
||||||
|
require(!MemberFilesProtocol.servicePath(path) && !MemberFilesProtocol.servicePath(target)) { "reserved avatars" } |
||||||
|
repository.check(repository.request(location.copy(path = path), command, target, |
||||||
|
version = if (command == MemberFilesProtocol.MKDIR) ByteArray(16) else requireNotNull(entry).version)) |
||||||
|
LogManager.addLog("INFO", "Files", "mutation completed command=$command location=$location path=$path target=$target") |
||||||
|
if (_state.value.location == location) refresh() |
||||||
|
} catch (e: CancellationException) { throw e } |
||||||
|
catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "Files", "mutation failed command=$command location=$location: $e") |
||||||
|
if (_state.value.location == location) _state.value = _state.value.copy(status = (e as? FileStatusException)?.status ?: 9) |
||||||
|
} finally { _state.value = _state.value.copy(mutating = false) } |
||||||
|
} |
||||||
|
} |
||||||
|
fun clearError() { _state.value = _state.value.copy(status = null) } |
||||||
|
} |
||||||
Loading…
Reference in new issue