Add Nextcloud Files browsing (browse/download/upload/delete/rename)

New Files section reachable from Agenda's top bar: a WebDAV browser
against /remote.php/dav/files/<user>/ with folder navigation, file
download-and-open (via FileProvider + a viewer Intent), upload from
the system file picker, delete, create folder, and rename (WebDAV
MOVE). Extends DavXml's existing multistatus parser with
getcontentlength/getlastmodified rather than writing a second parser,
since the response/propstat/status merging logic it already has is
the fiddly part.

Verified live against the real account: browsed real folders/files,
created and deleted a test folder, and confirmed a file downloads
and reaches a view Intent correctly (no viewer app installed on the
emulator to actually open it, but the FileProvider hand-off worked).
This commit is contained in:
RomanNum3ral 2026-07-18 14:02:22 -04:00
parent 2bf9a32ead
commit b8fdd9b421
Signed by: RomanNum3ral
GPG Key ID: B94562C9B7535805
10 changed files with 604 additions and 5 deletions

View File

@ -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)

View File

@ -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 </propstat>
// 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
)
)
}

View File

@ -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/<user>/`) -
* 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<DavFile> {
val body = """<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:resourcetype/>
<d:getcontentlength/>
<d:getlastmodified/>
</d:prop>
</d:propfind>""".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<DavFile> { 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()
}
}

View File

@ -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<DavFile> = 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)
}
}

View File

@ -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 <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val handle = extras.createSavedStateHandle()
@Suppress("UNCHECKED_CAST")
return create(app.filesRepository, handle) as T
}
}

View File

@ -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") }

View File

@ -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<DavFile?>(null) }
var menuTarget by remember { mutableStateOf<DavFile?>(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}"))
}

View File

@ -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<DavFile> = 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<FilesBrowserUiState> = _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"
}

View File

@ -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(

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="exports" path="exports/" />
<cache-path name="downloads" path="downloads/" />
</paths>