diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/FileStatusText.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/FileStatusText.kt new file mode 100644 index 00000000..39a15b56 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/FileStatusText.kt @@ -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 +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberFilesScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberFilesScreen.kt new file mode 100644 index 00000000..2e8f05d9 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberFilesScreen.kt @@ -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(null) } + var editing by remember { mutableStateOf(null) } + var newFolder by rememberSaveable { mutableStateOf(false) } + var deleteEntry by remember { mutableStateOf(null) } + var properties by remember { mutableStateOf(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)) } + }) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/MemberFilesViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/MemberFilesViewModel.kt new file mode 100644 index 00000000..b4f0fe39 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/MemberFilesViewModel.kt @@ -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 = emptyList(), + val servers: List = emptyList(), val entries: List = 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 = _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 { 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("group") + val savedOwner = saved.get("owner") + val location = if (oldGroup == group && savedOwner == owner && _state.value.location == null) + FileLocation(gid, saved.get("server") ?: 0, owner, saved.get("path") ?: "") + else FileLocation(gid, saved.get("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 { 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() + 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 { 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) } +}