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.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import androidx.glance.appwidget.updateAll
|
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.NextcloudRepository
|
||||||
import com.homelab.ncal.data.repository.TalkRepository
|
import com.homelab.ncal.data.repository.TalkRepository
|
||||||
import com.homelab.ncal.notifications.CHANNEL_ID
|
import com.homelab.ncal.notifications.CHANNEL_ID
|
||||||
|
|
@ -23,6 +24,9 @@ class NcalApplication : Application() {
|
||||||
// than constructed alongside `repository` in onCreate().
|
// than constructed alongside `repository` in onCreate().
|
||||||
val talkRepository: TalkRepository by lazy { TalkRepository(this) }
|
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() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
SQLiteDatabase.loadLibs(this)
|
SQLiteDatabase.loadLibs(this)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ data class DavResponse(
|
||||||
val ctag: String? = null,
|
val ctag: String? = null,
|
||||||
val etag: String? = null,
|
val etag: String? = null,
|
||||||
val colorHex: 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 etag: String? = null
|
||||||
var colorHex: String? = null
|
var colorHex: String? = null
|
||||||
var calendarData: 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>
|
// Per-propstat scratch space, flushed into the accumulators above on </propstat>
|
||||||
// only if that propstat's status was 2xx.
|
// only if that propstat's status was 2xx.
|
||||||
|
|
@ -62,6 +66,8 @@ object DavXml {
|
||||||
var propEtag: String? = null
|
var propEtag: String? = null
|
||||||
var propColorHex: String? = null
|
var propColorHex: String? = null
|
||||||
var propCalendarData: String? = null
|
var propCalendarData: String? = null
|
||||||
|
var propContentLength: Long? = null
|
||||||
|
var propLastModified: String? = null
|
||||||
|
|
||||||
var event = parser.eventType
|
var event = parser.eventType
|
||||||
while (event != XmlPullParser.END_DOCUMENT) {
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
|
@ -70,7 +76,7 @@ object DavXml {
|
||||||
when (parser.name) {
|
when (parser.name) {
|
||||||
"response" -> {
|
"response" -> {
|
||||||
href = null; displayName = null; ctag = null; etag = null
|
href = null; displayName = null; ctag = null; etag = null
|
||||||
colorHex = null; calendarData = null
|
colorHex = null; calendarData = null; contentLength = null; lastModified = null
|
||||||
resourceTypes.clear(); supportedComponents.clear()
|
resourceTypes.clear(); supportedComponents.clear()
|
||||||
}
|
}
|
||||||
"propstat" -> {
|
"propstat" -> {
|
||||||
|
|
@ -78,6 +84,7 @@ object DavXml {
|
||||||
propOk = true
|
propOk = true
|
||||||
propDisplayName = null; propCtag = null; propEtag = null
|
propDisplayName = null; propCtag = null; propEtag = null
|
||||||
propColorHex = null; propCalendarData = null
|
propColorHex = null; propCalendarData = null
|
||||||
|
propContentLength = null; propLastModified = null
|
||||||
propResourceTypes.clear(); propSupportedComponents.clear()
|
propResourceTypes.clear(); propSupportedComponents.clear()
|
||||||
}
|
}
|
||||||
"href" -> href = parser.nextTextSafe()
|
"href" -> href = parser.nextTextSafe()
|
||||||
|
|
@ -89,6 +96,8 @@ object DavXml {
|
||||||
"getetag" -> if (inPropstat) propEtag = parser.nextTextSafe()
|
"getetag" -> if (inPropstat) propEtag = parser.nextTextSafe()
|
||||||
"calendar-color" -> if (inPropstat) propColorHex = parser.nextTextSafe()?.take(7)
|
"calendar-color" -> if (inPropstat) propColorHex = parser.nextTextSafe()?.take(7)
|
||||||
"calendar-data" -> if (inPropstat) propCalendarData = parser.nextTextSafe()
|
"calendar-data" -> if (inPropstat) propCalendarData = parser.nextTextSafe()
|
||||||
|
"getcontentlength" -> if (inPropstat) propContentLength = parser.nextTextSafe()?.toLongOrNull()
|
||||||
|
"getlastmodified" -> if (inPropstat) propLastModified = parser.nextTextSafe()
|
||||||
"status" -> {
|
"status" -> {
|
||||||
val status = parser.nextTextSafe() ?: ""
|
val status = parser.nextTextSafe() ?: ""
|
||||||
if (inPropstat) propOk = status.contains(" 2") // e.g. "HTTP/1.1 200 OK"
|
if (inPropstat) propOk = status.contains(" 2") // e.g. "HTTP/1.1 200 OK"
|
||||||
|
|
@ -104,6 +113,8 @@ object DavXml {
|
||||||
propEtag?.let { etag = it }
|
propEtag?.let { etag = it }
|
||||||
propColorHex?.let { colorHex = it }
|
propColorHex?.let { colorHex = it }
|
||||||
propCalendarData?.let { calendarData = it }
|
propCalendarData?.let { calendarData = it }
|
||||||
|
propContentLength?.let { contentLength = it }
|
||||||
|
propLastModified?.let { lastModified = it }
|
||||||
resourceTypes.addAll(propResourceTypes)
|
resourceTypes.addAll(propResourceTypes)
|
||||||
supportedComponents.addAll(propSupportedComponents)
|
supportedComponents.addAll(propSupportedComponents)
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +131,9 @@ object DavXml {
|
||||||
ctag = ctag,
|
ctag = ctag,
|
||||||
etag = etag,
|
etag = etag,
|
||||||
colorHex = colorHex,
|
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.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Chat
|
import androidx.compose.material.icons.filled.Chat
|
||||||
import androidx.compose.material.icons.filled.Event
|
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.List
|
||||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
|
@ -58,7 +59,8 @@ fun AgendaScreen(
|
||||||
onOpenMonth: () -> Unit,
|
onOpenMonth: () -> Unit,
|
||||||
onOpenWeek: () -> Unit,
|
onOpenWeek: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onOpenTalk: () -> Unit
|
onOpenTalk: () -> Unit,
|
||||||
|
onOpenFiles: () -> Unit
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
|
@ -71,6 +73,7 @@ fun AgendaScreen(
|
||||||
CenterAlignedTopAppBar(
|
CenterAlignedTopAppBar(
|
||||||
title = { Text("Agenda") },
|
title = { Text("Agenda") },
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenFiles) { Icon(Icons.Filled.Folder, "Files") }
|
||||||
IconButton(onClick = onOpenTalk) { Icon(Icons.Filled.Chat, "Talk") }
|
IconButton(onClick = onOpenTalk) { Icon(Icons.Filled.Chat, "Talk") }
|
||||||
IconButton(onClick = onOpenSearch) { Icon(Icons.Filled.Search, "Search") }
|
IconButton(onClick = onOpenSearch) { Icon(Icons.Filled.Search, "Search") }
|
||||||
IconButton(onClick = onOpenWeek) { Icon(Icons.Filled.ViewWeek, "Week view") }
|
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 androidx.navigation.navArgument
|
||||||
import com.homelab.ncal.NcalApplication
|
import com.homelab.ncal.NcalApplication
|
||||||
import com.homelab.ncal.data.model.CalendarItem
|
import com.homelab.ncal.data.model.CalendarItem
|
||||||
|
import com.homelab.ncal.ui.FilesViewModelFactory
|
||||||
import com.homelab.ncal.ui.NcalViewModelFactory
|
import com.homelab.ncal.ui.NcalViewModelFactory
|
||||||
import com.homelab.ncal.ui.TalkViewModelFactory
|
import com.homelab.ncal.ui.TalkViewModelFactory
|
||||||
import com.homelab.ncal.ui.agenda.AgendaScreen
|
import com.homelab.ncal.ui.agenda.AgendaScreen
|
||||||
import com.homelab.ncal.ui.agenda.AgendaViewModel
|
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.CollectionsScreen
|
||||||
import com.homelab.ncal.ui.collections.CollectionsViewModel
|
import com.homelab.ncal.ui.collections.CollectionsViewModel
|
||||||
import com.homelab.ncal.ui.detail.ItemEditScreen
|
import com.homelab.ncal.ui.detail.ItemEditScreen
|
||||||
|
|
@ -60,6 +63,7 @@ private object Routes {
|
||||||
const val EDIT = "edit/{type}/{href}/{calendarUrl}"
|
const val EDIT = "edit/{type}/{href}/{calendarUrl}"
|
||||||
const val TALK_ROOMS = "talk"
|
const val TALK_ROOMS = "talk"
|
||||||
const val TALK_CHAT = "talk/{token}/{name}"
|
const val TALK_CHAT = "talk/{token}/{name}"
|
||||||
|
const val FILES = "files"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun editRoute(type: String, href: String?, calendarUrl: String? = null): String {
|
private fun editRoute(type: String, href: String?, calendarUrl: String? = null): String {
|
||||||
|
|
@ -136,7 +140,8 @@ fun NcalNavGraph(
|
||||||
onOpenMonth = { navController.navigate(Routes.MONTH) },
|
onOpenMonth = { navController.navigate(Routes.MONTH) },
|
||||||
onOpenWeek = { navController.navigate(Routes.WEEK) },
|
onOpenWeek = { navController.navigate(Routes.WEEK) },
|
||||||
onOpenSearch = { navController.navigate(Routes.SEARCH) },
|
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() })
|
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(
|
composable(
|
||||||
route = Routes.EDIT,
|
route = Routes.EDIT,
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<paths>
|
<paths>
|
||||||
<cache-path name="exports" path="exports/" />
|
<cache-path name="exports" path="exports/" />
|
||||||
|
<cache-path name="downloads" path="downloads/" />
|
||||||
</paths>
|
</paths>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue