diff --git a/ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt b/ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt index 2217f8b..6a5d931 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt @@ -4,6 +4,7 @@ import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import androidx.glance.appwidget.updateAll +import com.homelab.ncal.data.repository.FilesRepository import com.homelab.ncal.data.repository.NextcloudRepository import com.homelab.ncal.data.repository.TalkRepository import com.homelab.ncal.notifications.CHANNEL_ID @@ -23,6 +24,9 @@ class NcalApplication : Application() { // than constructed alongside `repository` in onCreate(). val talkRepository: TalkRepository by lazy { TalkRepository(this) } + // Same reasoning as talkRepository - Files browsing has no local cache either. + val filesRepository: FilesRepository by lazy { FilesRepository(this) } + override fun onCreate() { super.onCreate() SQLiteDatabase.loadLibs(this) diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/network/DavXml.kt b/ncal/app/src/main/java/com/homelab/ncal/data/network/DavXml.kt index cfaa35f..ef24219 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/data/network/DavXml.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/data/network/DavXml.kt @@ -13,7 +13,9 @@ data class DavResponse( val ctag: String? = null, val etag: String? = null, val colorHex: String? = null, - val calendarData: String? = null + val calendarData: String? = null, + val contentLength: Long? = null, + val lastModified: String? = null ) /** @@ -50,6 +52,8 @@ object DavXml { var etag: String? = null var colorHex: String? = null var calendarData: String? = null + var contentLength: Long? = null + var lastModified: String? = null // Per-propstat scratch space, flushed into the accumulators above on // only if that propstat's status was 2xx. @@ -62,6 +66,8 @@ object DavXml { var propEtag: String? = null var propColorHex: String? = null var propCalendarData: String? = null + var propContentLength: Long? = null + var propLastModified: String? = null var event = parser.eventType while (event != XmlPullParser.END_DOCUMENT) { @@ -70,7 +76,7 @@ object DavXml { when (parser.name) { "response" -> { href = null; displayName = null; ctag = null; etag = null - colorHex = null; calendarData = null + colorHex = null; calendarData = null; contentLength = null; lastModified = null resourceTypes.clear(); supportedComponents.clear() } "propstat" -> { @@ -78,6 +84,7 @@ object DavXml { propOk = true propDisplayName = null; propCtag = null; propEtag = null propColorHex = null; propCalendarData = null + propContentLength = null; propLastModified = null propResourceTypes.clear(); propSupportedComponents.clear() } "href" -> href = parser.nextTextSafe() @@ -89,6 +96,8 @@ object DavXml { "getetag" -> if (inPropstat) propEtag = parser.nextTextSafe() "calendar-color" -> if (inPropstat) propColorHex = parser.nextTextSafe()?.take(7) "calendar-data" -> if (inPropstat) propCalendarData = parser.nextTextSafe() + "getcontentlength" -> if (inPropstat) propContentLength = parser.nextTextSafe()?.toLongOrNull() + "getlastmodified" -> if (inPropstat) propLastModified = parser.nextTextSafe() "status" -> { val status = parser.nextTextSafe() ?: "" if (inPropstat) propOk = status.contains(" 2") // e.g. "HTTP/1.1 200 OK" @@ -104,6 +113,8 @@ object DavXml { propEtag?.let { etag = it } propColorHex?.let { colorHex = it } propCalendarData?.let { calendarData = it } + propContentLength?.let { contentLength = it } + propLastModified?.let { lastModified = it } resourceTypes.addAll(propResourceTypes) supportedComponents.addAll(propSupportedComponents) } @@ -120,7 +131,9 @@ object DavXml { ctag = ctag, etag = etag, colorHex = colorHex, - calendarData = calendarData + calendarData = calendarData, + contentLength = contentLength, + lastModified = lastModified ) ) } diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/network/FilesClient.kt b/ncal/app/src/main/java/com/homelab/ncal/data/network/FilesClient.kt new file mode 100644 index 0000000..60f15e7 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/data/network/FilesClient.kt @@ -0,0 +1,153 @@ +package com.homelab.ncal.data.network + +import okhttp3.Credentials +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.OutputStream +import java.net.URLDecoder +import java.net.URLEncoder +import java.util.concurrent.TimeUnit + +data class DavFile( + val name: String, + val path: String, + val isDirectory: Boolean, + val size: Long, + val lastModified: String? +) + +/** + * Plain WebDAV browsing of a user's Nextcloud Files space (`/remote.php/dav/files//`) - + * browse, download, upload, delete, create folder, rename/move. Uses the same app-password + * Basic Auth as CalDAV/Talk. + */ +class FilesClient( + private val serverUrl: String, + private val username: String, + private val appPassword: String +) { + private val http = OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + + private val authHeader = Credentials.basic(username, appPassword) + private val filesHomeUrl = "${serverUrl.trimEnd('/')}/remote.php/dav/files/$username/" + + private fun urlFor(path: String): String { + val encoded = path.trim('/').split('/').filter { it.isNotEmpty() } + .joinToString("/") { URLEncoder.encode(it, "UTF-8").replace("+", "%20") } + return filesHomeUrl + encoded + } + + /** Lists the contents of [path] ("" for the root of the Files space). */ + fun listDirectory(path: String): List { + val body = """ + + + + + + + """.trimIndent() + + val target = urlFor(path) + val request = Request.Builder() + .url(target) + .header("Authorization", authHeader) + .header("Content-Type", "application/xml; charset=utf-8") + .header("Depth", "1") + .method("PROPFIND", body.toRequestBody(XML_MEDIA)) + .build() + + val responseXml = executeText(request) + val all = DavXml.parseMultistatus(responseXml) + val targetPath = java.net.URI(target).rawPath + + return all + .filter { java.net.URI(it.href).rawPath?.trimEnd('/') != targetPath?.trimEnd('/') } + .map { dav -> + val decodedHref = URLDecoder.decode(dav.href, "UTF-8") + val name = decodedHref.trimEnd('/').substringAfterLast('/') + val relativePath = decodedHref.substringAfter("/remote.php/dav/files/$username/").trimEnd('/') + DavFile( + name = name, + path = relativePath, + isDirectory = dav.resourceTypes.contains("collection"), + size = dav.contentLength ?: 0L, + lastModified = dav.lastModified + ) + } + .sortedWith(compareByDescending { it.isDirectory }.thenBy { it.name.lowercase() }) + } + + fun downloadTo(path: String, output: OutputStream) { + val request = Request.Builder().url(urlFor(path)).header("Authorization", authHeader).get().build() + http.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) { + throw CalDavException("HTTP ${resp.code} downloading $path: ${resp.message}", resp.code) + } + resp.body?.byteStream()?.use { input -> input.copyTo(output) } + } + } + + fun upload(path: String, data: ByteArray, contentType: String) { + val request = Request.Builder() + .url(urlFor(path)) + .header("Authorization", authHeader) + .put(data.toRequestBody(contentType.toMediaType())) + .build() + executeNoBody(request) + } + + fun delete(path: String) { + val request = Request.Builder().url(urlFor(path)).header("Authorization", authHeader).delete().build() + executeNoBody(request) + } + + fun createFolder(path: String) { + val request = Request.Builder() + .url(urlFor(path)) + .header("Authorization", authHeader) + .method("MKCOL", null) + .build() + executeNoBody(request) + } + + fun move(fromPath: String, toPath: String) { + val request = Request.Builder() + .url(urlFor(fromPath)) + .header("Authorization", authHeader) + .header("Destination", urlFor(toPath)) + .header("Overwrite", "F") + .method("MOVE", null) + .build() + executeNoBody(request) + } + + private fun executeText(request: Request): String { + http.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) { + val detail = resp.body?.string()?.take(300) + throw CalDavException("HTTP ${resp.code} for ${request.method} ${request.url}: ${detail ?: resp.message}", resp.code) + } + return resp.body?.string() ?: "" + } + } + + private fun executeNoBody(request: Request) { + http.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) { + val detail = resp.body?.string()?.take(300) + throw CalDavException("HTTP ${resp.code} for ${request.method} ${request.url}: ${detail ?: resp.message}", resp.code) + } + } + } + + private companion object { + val XML_MEDIA = "application/xml; charset=utf-8".toMediaType() + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/repository/FilesRepository.kt b/ncal/app/src/main/java/com/homelab/ncal/data/repository/FilesRepository.kt new file mode 100644 index 0000000..227ee37 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/data/repository/FilesRepository.kt @@ -0,0 +1,46 @@ +package com.homelab.ncal.data.repository + +import android.content.Context +import com.homelab.ncal.data.network.DavFile +import com.homelab.ncal.data.network.FilesClient +import com.homelab.ncal.data.prefs.SecurePrefs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.OutputStream + +/** Thin pass-through to [FilesClient] - Files browsing has no local cache, same as Talk. */ +class FilesRepository(context: Context) { + + private val prefs = SecurePrefs(context) + + private fun client(): FilesClient { + val server = prefs.serverUrl ?: error("Not logged in") + val user = prefs.username ?: error("Not logged in") + val pass = prefs.appPassword ?: error("Not logged in") + return FilesClient(server, user, pass) + } + + suspend fun listDirectory(path: String): List = withContext(Dispatchers.IO) { + client().listDirectory(path) + } + + suspend fun downloadTo(path: String, output: OutputStream) = withContext(Dispatchers.IO) { + client().downloadTo(path, output) + } + + suspend fun upload(path: String, data: ByteArray, contentType: String) = withContext(Dispatchers.IO) { + client().upload(path, data, contentType) + } + + suspend fun delete(path: String) = withContext(Dispatchers.IO) { + client().delete(path) + } + + suspend fun createFolder(path: String) = withContext(Dispatchers.IO) { + client().createFolder(path) + } + + suspend fun move(fromPath: String, toPath: String) = withContext(Dispatchers.IO) { + client().move(fromPath, toPath) + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/FilesViewModelFactory.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/FilesViewModelFactory.kt new file mode 100644 index 0000000..88ce882 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/FilesViewModelFactory.kt @@ -0,0 +1,20 @@ +package com.homelab.ncal.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras +import com.homelab.ncal.NcalApplication +import com.homelab.ncal.data.repository.FilesRepository + +class FilesViewModelFactory( + private val app: NcalApplication, + private val create: (FilesRepository, SavedStateHandle) -> ViewModel +) : ViewModelProvider.Factory { + override fun create(modelClass: Class, extras: CreationExtras): T { + val handle = extras.createSavedStateHandle() + @Suppress("UNCHECKED_CAST") + return create(app.filesRepository, handle) as T + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt index 5b6c2d2..e7fcd07 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Chat import androidx.compose.material.icons.filled.Event +import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.Refresh @@ -58,7 +59,8 @@ fun AgendaScreen( onOpenMonth: () -> Unit, onOpenWeek: () -> Unit, onOpenSearch: () -> Unit, - onOpenTalk: () -> Unit + onOpenTalk: () -> Unit, + onOpenFiles: () -> Unit ) { val state by viewModel.state.collectAsState() @@ -71,6 +73,7 @@ fun AgendaScreen( CenterAlignedTopAppBar( title = { Text("Agenda") }, actions = { + IconButton(onClick = onOpenFiles) { Icon(Icons.Filled.Folder, "Files") } IconButton(onClick = onOpenTalk) { Icon(Icons.Filled.Chat, "Talk") } IconButton(onClick = onOpenSearch) { Icon(Icons.Filled.Search, "Search") } IconButton(onClick = onOpenWeek) { Icon(Icons.Filled.ViewWeek, "Week view") } diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/files/FilesBrowserScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/files/FilesBrowserScreen.kt new file mode 100644 index 0000000..8e437f5 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/files/FilesBrowserScreen.kt @@ -0,0 +1,250 @@ +package com.homelab.ncal.ui.files + +import android.content.Intent +import android.webkit.MimeTypeMap +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import com.homelab.ncal.data.network.DavFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilesBrowserScreen( + viewModel: FilesBrowserViewModel, + onExitRoot: () -> Unit +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var showNewFolderDialog by remember { mutableStateOf(false) } + var showFabMenu by remember { mutableStateOf(false) } + var renameTarget by remember { mutableStateOf(null) } + var menuTarget by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { viewModel.refresh() } + BackHandler(enabled = state.path.isNotBlank()) { viewModel.navigateUp() } + + val uploadLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + val (name, mimeType, bytes) = withContext(Dispatchers.IO) { + val resolver = context.contentResolver + var displayName = "upload" + resolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (cursor.moveToFirst() && nameIndex >= 0) displayName = cursor.getString(nameIndex) + } + val mime = resolver.getType(uri) ?: "application/octet-stream" + val data = resolver.openInputStream(uri)?.use { it.readBytes() } ?: ByteArray(0) + Triple(displayName, mime, data) + } + viewModel.upload(name, bytes, mimeType) + } + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text(viewModel.currentFolderName) }, + navigationIcon = { + IconButton(onClick = { if (!viewModel.navigateUp()) onExitRoot() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + } + ) + }, + floatingActionButton = { + Box { + ExtendedFloatingActionButton(onClick = { showFabMenu = true }, text = { Text("Add") }, icon = { Icon(Icons.Filled.Add, null) }) + DropdownMenu(expanded = showFabMenu, onDismissRequest = { showFabMenu = false }) { + DropdownMenuItem( + text = { Text("Upload file") }, + leadingIcon = { Icon(Icons.Filled.UploadFile, null) }, + onClick = { showFabMenu = false; uploadLauncher.launch("*/*") } + ) + DropdownMenuItem( + text = { Text("New folder") }, + leadingIcon = { Icon(Icons.Filled.CreateNewFolder, null) }, + onClick = { showFabMenu = false; showNewFolderDialog = true } + ) + } + } + } + ) { padding -> + PullToRefreshBox( + isRefreshing = state.loading, + onRefresh = viewModel::refresh, + modifier = Modifier.fillMaxSize().padding(padding) + ) { + Column(Modifier.fillMaxSize()) { + state.error?.let { + Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) + } + if (state.busy) { + CircularProgressIndicator(modifier = Modifier.padding(16.dp)) + } + if (state.entries.isEmpty() && !state.loading && state.error == null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("This folder is empty.") + } + } + LazyColumn { + items(state.entries, key = { it.path }) { file -> + ListItem( + modifier = Modifier.fillMaxWidth().clickable { + if (file.isDirectory) { + viewModel.open(file) + } else { + scope.launch { openWithViewer(context, viewModel, file) } + } + }, + headlineContent = { Text(file.name) }, + supportingContent = { + if (!file.isDirectory) Text(formatSize(file.size)) + }, + leadingContent = { + Icon( + if (file.isDirectory) Icons.Filled.Folder else Icons.Filled.InsertDriveFile, + contentDescription = null + ) + }, + trailingContent = { + Box { + IconButton(onClick = { menuTarget = file }) { + Icon(Icons.Filled.Edit, "Options") + } + DropdownMenu(expanded = menuTarget == file, onDismissRequest = { menuTarget = null }) { + DropdownMenuItem( + text = { Text("Rename") }, + leadingIcon = { Icon(Icons.Filled.Edit, null) }, + onClick = { menuTarget = null; renameTarget = file } + ) + DropdownMenuItem( + text = { Text("Delete") }, + leadingIcon = { Icon(Icons.Filled.Delete, null) }, + onClick = { menuTarget = null; viewModel.delete(file) } + ) + } + } + } + ) + HorizontalDivider() + } + } + } + } + } + + if (showNewFolderDialog) { + NameDialog( + title = "New folder", + initial = "", + onConfirm = { name -> showNewFolderDialog = false; viewModel.createFolder(name) }, + onDismiss = { showNewFolderDialog = false } + ) + } + + renameTarget?.let { file -> + NameDialog( + title = "Rename", + initial = file.name, + onConfirm = { name -> renameTarget = null; viewModel.rename(file, name) }, + onDismiss = { renameTarget = null } + ) + } +} + +@Composable +private fun NameDialog(title: String, initial: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) { + var text by remember { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + OutlinedTextField(value = text, onValueChange = { text = it }, singleLine = true) + }, + confirmButton = { + TextButton(onClick = { if (text.isNotBlank()) onConfirm(text.trim()) }) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} + +private fun formatSize(bytes: Long): String = when { + bytes >= 1_000_000_000 -> "%.1f GB".format(bytes / 1_000_000_000.0) + bytes >= 1_000_000 -> "%.1f MB".format(bytes / 1_000_000.0) + bytes >= 1_000 -> "%.1f KB".format(bytes / 1_000.0) + else -> "$bytes B" +} + +/** Downloads [file] to a cache file and opens it with whatever app the device has for its type. */ +internal suspend fun openWithViewer(context: android.content.Context, viewModel: FilesBrowserViewModel, file: DavFile) { + val dir = File(context.cacheDir, "downloads").apply { mkdirs() } + val target = File(dir, file.name) + withContext(Dispatchers.IO) { + FileOutputStream(target).use { out -> viewModel.download(file, out) } + } + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", target) + val extension = file.name.substringAfterLast('.', "") + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "*/*" + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, mimeType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Open ${file.name}")) +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/files/FilesBrowserViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/files/FilesBrowserViewModel.kt new file mode 100644 index 0000000..03192d8 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/files/FilesBrowserViewModel.kt @@ -0,0 +1,99 @@ +package com.homelab.ncal.ui.files + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.homelab.ncal.data.network.DavFile +import com.homelab.ncal.data.repository.FilesRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.OutputStream + +data class FilesBrowserUiState( + val path: String = "", + val entries: List = emptyList(), + val loading: Boolean = false, + val busy: Boolean = false, + val error: String? = null +) + +class FilesBrowserViewModel(private val repo: FilesRepository) : ViewModel() { + + private val _state = MutableStateFlow(FilesBrowserUiState()) + val state: StateFlow = _state + + /** The folder name shown in the top bar - "Files" at the root. */ + val currentFolderName: String + get() = _state.value.path.substringAfterLast('/').ifBlank { "Files" } + + fun refresh() { + val path = _state.value.path + _state.value = _state.value.copy(loading = true, error = null) + viewModelScope.launch { + try { + val entries = repo.listDirectory(path) + _state.value = _state.value.copy(entries = entries, loading = false) + } catch (e: Exception) { + _state.value = _state.value.copy(loading = false, error = e.message ?: "Couldn't load folder") + } + } + } + + fun open(file: DavFile) { + if (!file.isDirectory) return + _state.value = _state.value.copy(path = file.path) + refresh() + } + + /** Pops one directory level. Returns false if already at the root (caller should navigate away). */ + fun navigateUp(): Boolean { + val path = _state.value.path + if (path.isBlank()) return false + val parent = path.substringBeforeLast('/', missingDelimiterValue = "") + _state.value = _state.value.copy(path = parent) + refresh() + return true + } + + fun createFolder(name: String) { + val newPath = joinPath(_state.value.path, name) + runBusy { repo.createFolder(newPath) } + } + + fun delete(file: DavFile) { + runBusy { repo.delete(file.path) } + } + + fun rename(file: DavFile, newName: String) { + val parent = file.path.substringBeforeLast('/', missingDelimiterValue = "") + val newPath = joinPath(parent, newName) + runBusy { repo.move(file.path, newPath) } + } + + fun upload(name: String, bytes: ByteArray, mimeType: String) { + val newPath = joinPath(_state.value.path, name) + runBusy { repo.upload(newPath, bytes, mimeType) } + } + + /** Downloads [file] into [output] - the caller owns opening/closing the stream (a cache + * file it then hands to a viewer Intent), this just does the network transfer. */ + suspend fun download(file: DavFile, output: OutputStream) { + repo.downloadTo(file.path, output) + } + + private fun runBusy(block: suspend () -> Unit) { + _state.value = _state.value.copy(busy = true, error = null) + viewModelScope.launch { + try { + block() + _state.value = _state.value.copy(busy = false) + refresh() + } catch (e: Exception) { + _state.value = _state.value.copy(busy = false, error = e.message ?: "That didn't work") + } + } + } + + private fun joinPath(parent: String, child: String): String = + if (parent.isBlank()) child else "$parent/$child" +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt index 09b836d..07d8276 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt @@ -24,10 +24,13 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.homelab.ncal.NcalApplication import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.ui.FilesViewModelFactory import com.homelab.ncal.ui.NcalViewModelFactory import com.homelab.ncal.ui.TalkViewModelFactory import com.homelab.ncal.ui.agenda.AgendaScreen import com.homelab.ncal.ui.agenda.AgendaViewModel +import com.homelab.ncal.ui.files.FilesBrowserScreen +import com.homelab.ncal.ui.files.FilesBrowserViewModel import com.homelab.ncal.ui.collections.CollectionsScreen import com.homelab.ncal.ui.collections.CollectionsViewModel import com.homelab.ncal.ui.detail.ItemEditScreen @@ -60,6 +63,7 @@ private object Routes { const val EDIT = "edit/{type}/{href}/{calendarUrl}" const val TALK_ROOMS = "talk" const val TALK_CHAT = "talk/{token}/{name}" + const val FILES = "files" } private fun editRoute(type: String, href: String?, calendarUrl: String? = null): String { @@ -136,7 +140,8 @@ fun NcalNavGraph( onOpenMonth = { navController.navigate(Routes.MONTH) }, onOpenWeek = { navController.navigate(Routes.WEEK) }, onOpenSearch = { navController.navigate(Routes.SEARCH) }, - onOpenTalk = { navController.navigate(Routes.TALK_ROOMS) } + onOpenTalk = { navController.navigate(Routes.TALK_ROOMS) }, + onOpenFiles = { navController.navigate(Routes.FILES) } ) } @@ -205,6 +210,11 @@ fun NcalNavGraph( TalkChatScreen(viewModel = vm, roomName = roomName, onBack = { navController.popBackStack() }) } + composable(Routes.FILES) { + val vm: FilesBrowserViewModel = viewModel(factory = FilesViewModelFactory(app) { repo, _ -> FilesBrowserViewModel(repo) }) + FilesBrowserScreen(viewModel = vm, onExitRoot = { navController.popBackStack() }) + } + composable( route = Routes.EDIT, arguments = listOf( diff --git a/ncal/app/src/main/res/xml/file_paths.xml b/ncal/app/src/main/res/xml/file_paths.xml index 282e644..2da14ce 100644 --- a/ncal/app/src/main/res/xml/file_paths.xml +++ b/ncal/app/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ +