From 8643fef813a5ac22025dd308d33a2cbeb8a01583 Mon Sep 17 00:00:00 2001 From: Felipe Martinez Date: Mon, 25 Mar 2024 03:18:38 +0100 Subject: [PATCH] Add file browser --- .../java/net/pipe01/pinepartner/NavFrame.kt | 17 ++ .../pipe01/pinepartner/devices/blefs/FS.kt | 107 ++++---- .../pipe01/pinepartner/devices/blefs/File.kt | 8 +- .../pinepartner/pages/devices/DevicePage.kt | 5 + .../pages/devices/FileBrowserPage.kt | 231 ++++++++++++++++++ .../pinepartner/service/BackgroundService.kt | 8 + .../pipe01/pinepartner/utils/ExpandableFAB.kt | 86 +++++++ 7 files changed, 416 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/net/pipe01/pinepartner/pages/devices/FileBrowserPage.kt create mode 100644 app/src/main/java/net/pipe01/pinepartner/utils/ExpandableFAB.kt diff --git a/app/src/main/java/net/pipe01/pinepartner/NavFrame.kt b/app/src/main/java/net/pipe01/pinepartner/NavFrame.kt index 35d64d3..1c96d12 100644 --- a/app/src/main/java/net/pipe01/pinepartner/NavFrame.kt +++ b/app/src/main/java/net/pipe01/pinepartner/NavFrame.kt @@ -40,6 +40,7 @@ import net.pipe01.pinepartner.pages.devices.AddDevicePage import net.pipe01.pinepartner.pages.devices.DFUPage import net.pipe01.pinepartner.pages.devices.DevicePage import net.pipe01.pinepartner.pages.devices.DevicesPage +import net.pipe01.pinepartner.pages.devices.FileBrowserPage import net.pipe01.pinepartner.pages.plugins.CodeViewerPage import net.pipe01.pinepartner.pages.plugins.ImportPluginPage import net.pipe01.pinepartner.pages.plugins.PluginPage @@ -47,6 +48,8 @@ import net.pipe01.pinepartner.pages.plugins.PluginsPage import net.pipe01.pinepartner.pages.settings.NotificationSettingsPage import net.pipe01.pinepartner.pages.settings.SettingsPage import net.pipe01.pinepartner.service.BackgroundService +import java.net.URLDecoder +import java.net.URLEncoder object Route { const val DEVICES = "devices" @@ -223,6 +226,7 @@ fun NavFrame( db = db, deviceAddress = address!!, onUploadFirmware = { navController.navigate("${Route.DEVICES}/$address/dfu") }, + onBrowseFiles = { navController.navigate("${Route.DEVICES}/$address/files") }, ) } composable("${Route.DEVICES}/{address}/dfu") { @@ -238,6 +242,19 @@ fun NavFrame( }, ) } + composable("${Route.DEVICES}/{address}/files?path={path}") { + val address = it.arguments?.getString("address") + val path = it.arguments?.getString("path")?.let { URLDecoder.decode(it, "utf8") } + + FileBrowserPage( + backgroundService = backgroundService, + deviceAddress = address!!, + path = path ?: "", + onOpenFolder = { newPath -> + navController.navigate("${Route.DEVICES}/$address/files?path=${URLEncoder.encode(newPath, "utf8")}") + }, + ) + } } } } diff --git a/app/src/main/java/net/pipe01/pinepartner/devices/blefs/FS.kt b/app/src/main/java/net/pipe01/pinepartner/devices/blefs/FS.kt index ac0652c..33642c1 100644 --- a/app/src/main/java/net/pipe01/pinepartner/devices/blefs/FS.kt +++ b/app/src/main/java/net/pipe01/pinepartner/devices/blefs/FS.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.util.Log import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import net.pipe01.pinepartner.devices.Device @@ -16,6 +17,33 @@ import java.time.Instant private const val TAG = "BLEFS" +fun joinPaths(path1: String, path2: String): String { + return cleanPath(if (path1 == "") { + path2 + } else { + "$path1/$path2" + }) +} + +fun cleanPath(path: String): String { + val parts = ArrayDeque(path.split("/")) + + while (!parts.isEmpty()) { + when (parts.last()) { + "", "." -> parts.removeLast() + ".." -> { + parts.removeLast() + if (!parts.isEmpty()) { + parts.removeLast() + } + } + else -> break + } + } + + return parts.joinToString("/") +} + @SuppressLint("MissingPermission") private suspend fun Device.doRequest( coroutineScope: CoroutineScope, @@ -42,6 +70,7 @@ private suspend fun Device.doRequest( start.await() + delay(1000) val request = onBuildRequest() fileService.write(DataByteArray(request.array())) @@ -109,49 +138,6 @@ suspend fun Device.readFile(path: String, coroutineScope: CoroutineScope): ByteA } } ) -// -// val job = coroutineScope.launch { -// fileService -// .getNotifications() -// .onStart { start.complete(Unit) } -// .collect { -// val buf = ByteBuffer.wrap(it.value).order(ByteOrder.LITTLE_ENDIAN) -// -// if (buf.get() != 0x11.toByte()) { -// Log.e(TAG, "Invalid file response") -// return@collect -// } -// -// val status = buf.get() -// buf.getShort() // Padding -// val offset = buf.getInt() -// val totalSize = buf.getInt() -// val chunkSize = buf.getInt() -// -// if (file == null) { -// file = ByteArray(totalSize) -// } -// -// it.value.copyInto(file!!, offset, buf.position(), buf.position() + chunkSize) -// -// Log.d(TAG, "Read response $offset/$totalSize bytes") -// -// val bytesRemaining = totalSize - offset - chunkSize -// -// if (bytesRemaining == 0) { -// done.complete(Unit) -// } else { -// val continueBuf = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN) -// continueBuf.put(0x12) -// continueBuf.put(0x01) -// continueBuf.putShort(0) // Padding -// continueBuf.putInt(offset + chunkSize) -// continueBuf.putInt(wantChunkSize) -// -// fileService.write(DataByteArray(continueBuf.array())) -// } -// } -// } Log.d(TAG, "File received, ${file?.size} bytes") return file ?: throw IllegalStateException("No file received") @@ -280,7 +266,8 @@ suspend fun Device.listFiles(path: String, coroutineScope: CoroutineScope): List files.add( File( - path = String(pathBuf), + name = String(pathBuf), + fullPath = joinPaths(path, String(pathBuf)), isDirectory = flags and 0x01u != 0u, modTime = Instant.ofEpochMilli(timestampNanos.toLong() / 1_000_000), size = size @@ -292,4 +279,36 @@ suspend fun Device.listFiles(path: String, coroutineScope: CoroutineScope): List ) return files +} + +@SuppressLint("MissingPermission") +suspend fun Device.createFolder(path: String, coroutineScope: CoroutineScope) { + doRequest( + coroutineScope = coroutineScope, + onBuildRequest = { + val pathBytes = path.toByteArray() + + ByteBuffer + .allocate(16 + pathBytes.size) + .order(ByteOrder.LITTLE_ENDIAN) + .put(0x40) + .put(0) // Padding + .putShort(pathBytes.size.toShort()) + .putInt(0) // Padding + .putLong(0) // Timestamp + .put(pathBytes) + }, + onReceiveResponse = { resp, _ -> + if (resp.get() != 0x41.toByte()) { + Log.e(TAG, "Invalid create folder response") + return@doRequest true + } + + val status = resp.get() + + Log.d(TAG, "Create folder status $status") + + true + } + ) } \ No newline at end of file diff --git a/app/src/main/java/net/pipe01/pinepartner/devices/blefs/File.kt b/app/src/main/java/net/pipe01/pinepartner/devices/blefs/File.kt index 77fe1d5..450efb7 100644 --- a/app/src/main/java/net/pipe01/pinepartner/devices/blefs/File.kt +++ b/app/src/main/java/net/pipe01/pinepartner/devices/blefs/File.kt @@ -1,10 +1,14 @@ package net.pipe01.pinepartner.devices.blefs +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import java.time.Instant +@Parcelize data class File( - val path: String, + val name: String, + val fullPath: String, val isDirectory: Boolean, val modTime: Instant, val size: UInt, -) +) : Parcelable diff --git a/app/src/main/java/net/pipe01/pinepartner/pages/devices/DevicePage.kt b/app/src/main/java/net/pipe01/pinepartner/pages/devices/DevicePage.kt index 382376e..86d17af 100644 --- a/app/src/main/java/net/pipe01/pinepartner/pages/devices/DevicePage.kt +++ b/app/src/main/java/net/pipe01/pinepartner/pages/devices/DevicePage.kt @@ -34,6 +34,7 @@ fun DevicePage( deviceAddress: String, backgroundService: BackgroundService, onUploadFirmware: () -> Unit, + onBrowseFiles: () -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -64,6 +65,10 @@ fun DevicePage( Text(text = "Upload firmware") } + Button(onClick = onBrowseFiles) { + Text(text = "Browse files") + } + Spacer(modifier = Modifier.height(10.dp)) Button(onClick = { diff --git a/app/src/main/java/net/pipe01/pinepartner/pages/devices/FileBrowserPage.kt b/app/src/main/java/net/pipe01/pinepartner/pages/devices/FileBrowserPage.kt new file mode 100644 index 0000000..94fb6a6 --- /dev/null +++ b/app/src/main/java/net/pipe01/pinepartner/pages/devices/FileBrowserPage.kt @@ -0,0 +1,231 @@ +package net.pipe01.pinepartner.pages.devices + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import net.pipe01.pinepartner.components.Header +import net.pipe01.pinepartner.components.LoadingStandIn +import net.pipe01.pinepartner.devices.blefs.File +import net.pipe01.pinepartner.devices.blefs.joinPaths +import net.pipe01.pinepartner.service.BackgroundService +import net.pipe01.pinepartner.utils.BoxWithFAB +import net.pipe01.pinepartner.utils.ExpandableFAB +import java.time.Instant + +@Composable +fun FileBrowserPage( + backgroundService: BackgroundService, + deviceAddress: String, + path: String, + onOpenFolder: (String) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + + val files = remember { mutableStateListOf() } + var isLoading by remember { mutableStateOf(true) } + + var showCreateFolderDialog by remember { mutableStateOf(false) } + + suspend fun reload() { + isLoading = true + files.clear() + files.addAll(backgroundService.listFiles(deviceAddress, path)) + isLoading = false + } + + LaunchedEffect(path) { + reload() + } + + if (showCreateFolderDialog) { + CreateFolderDialog( + onDismissRequest = { showCreateFolderDialog = false }, + onCreate = { name -> + showCreateFolderDialog = false + isLoading = true + + coroutineScope.launch { + backgroundService.createFolder(deviceAddress, joinPaths(path, name)) + reload() + } + }, + ) + } + + Column { + Header( + modifier = Modifier.padding(horizontal = 16.dp), + text = "/$path", + ) + + LoadingStandIn(isLoading = isLoading) { + BoxWithFAB(fab = { + ExpandableFAB( + modifier = it, + icon = Icons.Outlined.Add, + ) { + action( + icon = { Icon(Icons.Outlined.Folder, contentDescription = "Folder") }, + text = "New folder", + onClick = { showCreateFolderDialog = true } + ) + action( + icon = { Icon(Icons.Outlined.Description, contentDescription = "File") }, + text = "Send file", + onClick = { } + ) + } + }) { + FileList( + files = files + .filter { it.name != "." && (path != "" || it.name != "..") } + .sortedBy { it.name } + .sortedByDescending { it.isDirectory }, + onOpenFolder = onOpenFolder, + ) + } + } + } +} + +@Composable +private fun FileList( + files: List, + onOpenFolder: (String) -> Unit = { }, +) { + Column( + modifier = Modifier.padding(vertical = 16.dp), + ) { + for (file in files) { + FileListItem( + file = file, + onOpen = { + if (file.isDirectory) { + onOpenFolder(file.fullPath) + } + } + ) + } + } +} + +@Composable +private fun FileListItem( + file: File, + onOpen: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpen) + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (file.isDirectory) { + Icon(Icons.Outlined.Folder, contentDescription = "Folder") + } else { + Icon(Icons.Outlined.Description, contentDescription = "File") + } + + Spacer(modifier = Modifier.width(16.dp)) + + Text(text = file.name) + + if (!file.isDirectory) { + Spacer(modifier = Modifier.weight(1f)) + + Text( + modifier = Modifier + .padding(start = 8.dp) + .alpha(0.6f), + text = "${file.size} bytes", + ) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun CreateFolderDialog( + onDismissRequest: () -> Unit, + onCreate: (String) -> Unit, +) { + var name by remember { mutableStateOf("") } + + val (focusRequester) = FocusRequester.createRefs() + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton(onClick = { onCreate(name) }) { + Text(text = "Create") + } + }, + text = { + Column { + Text(text = "Enter new folder name") + + Spacer(modifier = Modifier.height(8.dp)) + + TextField( + value = name, + onValueChange = { name = it }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onCreate(name) }), + modifier = Modifier.focusRequester(focusRequester) + ) + } + } + ) +} + +@Preview(showBackground = true, widthDp = 320, heightDp = 640) +@Composable +private fun FileListPreview() { + FileList( + files = listOf( + File(".", "", true, Instant.now(), 0u), + File("..", "", true, Instant.now(), 0u), + File("test.txt", "test.txt", false, Instant.now(), 100u), + ) + ) +} diff --git a/app/src/main/java/net/pipe01/pinepartner/service/BackgroundService.kt b/app/src/main/java/net/pipe01/pinepartner/service/BackgroundService.kt index db8b358..0567119 100644 --- a/app/src/main/java/net/pipe01/pinepartner/service/BackgroundService.kt +++ b/app/src/main/java/net/pipe01/pinepartner/service/BackgroundService.kt @@ -30,6 +30,8 @@ import net.pipe01.pinepartner.R import net.pipe01.pinepartner.data.AppDatabase import net.pipe01.pinepartner.devices.DFUProgress import net.pipe01.pinepartner.devices.WatchState +import net.pipe01.pinepartner.devices.blefs.createFolder +import net.pipe01.pinepartner.devices.blefs.listFiles import net.pipe01.pinepartner.scripting.BuiltInPlugins import net.pipe01.pinepartner.scripting.LogEvent import net.pipe01.pinepartner.scripting.PluginManager @@ -277,4 +279,10 @@ class BackgroundService : Service() { suspend fun reloadPlugins() { pluginManager.reload() } + + suspend fun listFiles(address: String, path: String) + = deviceManager.get(address)?.listFiles(path, CoroutineScope(Dispatchers.IO)) ?: throw ServiceException("Device not found") + + suspend fun createFolder(address: String, path: String) + = deviceManager.get(address)?.createFolder(path, CoroutineScope(Dispatchers.IO)) ?: throw ServiceException("Device not found") } \ No newline at end of file diff --git a/app/src/main/java/net/pipe01/pinepartner/utils/ExpandableFAB.kt b/app/src/main/java/net/pipe01/pinepartner/utils/ExpandableFAB.kt new file mode 100644 index 0000000..dd2e7a1 --- /dev/null +++ b/app/src/main/java/net/pipe01/pinepartner/utils/ExpandableFAB.kt @@ -0,0 +1,86 @@ +package net.pipe01.pinepartner.utils + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +interface ExpandableFABScope { + @Composable + fun action(icon: @Composable () -> Unit, text: String, onClick: () -> Unit) +} + +@Composable +fun ExpandableFAB( + modifier: Modifier = Modifier, + icon: ImageVector, + content: @Composable ExpandableFABScope.() -> Unit, +) { + var isExpanded by remember { mutableStateOf(false) } + + //TODO: Add animations + + Column( + modifier = modifier, + horizontalAlignment = Alignment.End, + ) { + if (isExpanded) { + content(object : ExpandableFABScope { + @Composable + override fun action(icon: @Composable () -> Unit, text: String, onClick: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.background(MaterialTheme.colorScheme.background), + text = text, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + FloatingActionButton( + modifier = Modifier.scale(0.8f), + onClick = { + isExpanded = false + onClick() + }, + shape = CircleShape, + ) { + icon() + } + } + + Spacer(modifier = Modifier.height(12.dp)) + } + }) + } + + FloatingActionButton( + onClick = { isExpanded = !isExpanded }, + ) { + Icon( + imageVector = icon, + modifier = Modifier.rotate(if (isExpanded) 45f else 0f), + contentDescription = null, //TODO: Fill this + ) + } + } +} \ No newline at end of file