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:
parent
2bf9a32ead
commit
b8fdd9b421
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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}"))
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue