From 07a8a64054981e9b7c9a97869946cd3f600e7245 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 1 Apr 2026 13:53:27 -0500 Subject: [PATCH 1/4] feat(filetree): implement external file drag-and-drop import and export Add gesture-based drag initiation, cross-app file export, and robust URI import handling. --- .../viewholders/FileTreeViewHolder.java | 14 ++ .../androidide/dnd/DragAndDropExtensions.kt | 50 +++++++ .../itsaky/androidide/dnd/FileDragStarter.kt | 100 ++++++++++++++ .../sidebar/FileTreeDropController.kt | 111 +++++++++++++++ .../fragments/sidebar/FileTreeFragment.kt | 126 +++++++++++++++--- .../tasks/callables/FileTreeCallable.java | 23 +++- .../itsaky/androidide/utils/FileImporter.kt | 92 +++++++++++++ .../androidide/utils/UriFileImporter.kt | 66 +++++++++ .../viewmodels/PluginManagerViewModel.kt | 40 +----- resources/src/main/res/values/strings.xml | 3 + .../com/unnamed/b/atv/model/TreeNode.java | 14 ++ .../unnamed/b/atv/view/AndroidTreeView.java | 47 ++++--- .../unnamed/b/atv/view/NodeTouchHandler.java | 101 ++++++++++++++ 13 files changed, 702 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt create mode 100644 app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt create mode 100644 app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeDropController.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/FileImporter.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/UriFileImporter.kt create mode 100644 treeview/src/main/java/com/unnamed/b/atv/view/NodeTouchHandler.java diff --git a/app/src/main/java/com/itsaky/androidide/adapters/viewholders/FileTreeViewHolder.java b/app/src/main/java/com/itsaky/androidide/adapters/viewholders/FileTreeViewHolder.java index af3ead4299..069916f7b3 100755 --- a/app/src/main/java/com/itsaky/androidide/adapters/viewholders/FileTreeViewHolder.java +++ b/app/src/main/java/com/itsaky/androidide/adapters/viewholders/FileTreeViewHolder.java @@ -35,10 +35,20 @@ public class FileTreeViewHolder extends TreeNode.BaseNodeViewHolder { + public interface ExternalDropHandler { + void onNodeBound(TreeNode node, File file, View view); + } + private LayoutFiletreeItemBinding binding; + private final ExternalDropHandler externalDropHandler; public FileTreeViewHolder(Context context) { + this(context, null); + } + + public FileTreeViewHolder(Context context, ExternalDropHandler externalDropHandler) { super(context); + this.externalDropHandler = externalDropHandler; } @Override @@ -61,6 +71,10 @@ public View createNodeView(TreeNode node, File file) { chevron.setVisibility(View.INVISIBLE); } + if (externalDropHandler != null) { + externalDropHandler.onNodeBound(node, file, root); + } + return root; } diff --git a/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt b/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt new file mode 100644 index 0000000000..f1692388a0 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt @@ -0,0 +1,50 @@ +package com.itsaky.androidide.dnd + +import android.content.ClipData +import android.content.ClipDescription +import android.content.Context +import android.net.Uri +import android.view.DragEvent +import androidx.core.net.toUri + +/** + * Checks if the [DragEvent] contains any URIs that can be imported into the project. + */ +fun DragEvent.hasImportableContent(context: Context): Boolean { + if (localState != null) return false + + return when (action) { + DragEvent.ACTION_DROP -> { + val clip = clipData ?: return false + (0 until clip.itemCount).any { index -> + clip.getItemAt(index).toImportableExternalUri(context) != null + } + } + else -> clipDescription?.hasImportableMimeType() == true + } +} + +/** + * Resolves the [ClipData.Item] to an external [Uri], ignoring internal application URIs. + */ +fun ClipData.Item.toImportableExternalUri(context: Context): Uri? { + val resolvedUri = toExternalUri() ?: return null + return resolvedUri.takeUnless { it.isInternalDragUri(context) } +} + +private fun Uri.isInternalDragUri(context: Context): Boolean { + return authority == "${context.packageName}.providers.fileprovider" +} + +private fun ClipData.Item.toExternalUri(): Uri? { + return uri + ?: text?.toString() + ?.takeIf { it.startsWith("content://") || it.startsWith("file://") } + ?.toUri() +} + +private fun ClipDescription.hasImportableMimeType(): Boolean { + return hasMimeType(ClipDescription.MIMETYPE_TEXT_URILIST) || + hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) || + hasMimeType("*/*") +} diff --git a/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt b/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt new file mode 100644 index 0000000000..953ffef496 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt @@ -0,0 +1,100 @@ +package com.itsaky.androidide.dnd + +import android.content.ClipData +import android.content.Context +import android.net.Uri +import android.view.View +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import androidx.core.view.ViewCompat +import java.io.File +import java.util.Locale + +sealed interface FileDragResult { + data object Started : FileDragResult + data class NotStarted(val reason: FileDragFailureReason) : FileDragResult + data class Failed(val throwable: Throwable? = null) : FileDragResult +} + +enum class FileDragFailureReason { + FILE_NOT_FOUND, + NOT_A_FILE, + DRAG_NOT_STARTED, +} + +class FileDragStarter( + private val context: Context, +) { + + fun startDrag(sourceView: View, file: File): FileDragResult { + if (!file.exists()) { + return FileDragResult.NotStarted(FileDragFailureReason.FILE_NOT_FOUND) + } + + if (!file.isFile) { + return FileDragResult.NotStarted(FileDragFailureReason.NOT_A_FILE) + } + + return runCatching { + val contentUri = buildContentUri(file) + val mimeType = resolveMimeType(file) + val clipData = buildClipData(file, contentUri, mimeType) + val dragShadow = View.DragShadowBuilder(sourceView) + + ViewCompat.startDragAndDrop( + sourceView, + clipData, + dragShadow, + null, + DRAG_FLAGS, + ) + }.fold( + onSuccess = ::toDragResult, + onFailure = ::toFailureResult, + ) + } + + private fun buildContentUri(file: File): Uri { + return FileProvider.getUriForFile(context, fileProviderAuthority, file) + } + + private fun resolveMimeType(file: File): String { + val extension = file.extension.lowercase(Locale.ROOT) + return MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(extension) + ?: DEFAULT_MIME_TYPE + } + + private fun buildClipData( + file: File, + contentUri: Uri, + mimeType: String, + ): ClipData { + return ClipData( + file.name, + arrayOf(mimeType), + ClipData.Item(contentUri), + ) + } + + private fun toDragResult(started: Boolean): FileDragResult { + if (started) { + return FileDragResult.Started + } + + return FileDragResult.NotStarted(FileDragFailureReason.DRAG_NOT_STARTED) + } + + private fun toFailureResult(throwable: Throwable): FileDragResult { + return FileDragResult.Failed(throwable) + } + + private val fileProviderAuthority: String + get() = "${context.packageName}.providers.fileprovider" + + private companion object { + private const val DEFAULT_MIME_TYPE = "application/octet-stream" + private const val DRAG_FLAGS = + View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ + } +} diff --git a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeDropController.kt b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeDropController.kt new file mode 100644 index 0000000000..fb0bb25a7b --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeDropController.kt @@ -0,0 +1,111 @@ +package com.itsaky.androidide.fragments.sidebar + +import android.app.Activity +import android.view.DragEvent +import android.view.View +import com.itsaky.androidide.R +import com.itsaky.androidide.adapters.viewholders.FileTreeViewHolder +import com.itsaky.androidide.dnd.DragEventRouter +import com.itsaky.androidide.dnd.DropTargetCallback +import com.itsaky.androidide.dnd.hasImportableContent +import com.itsaky.androidide.tasks.executeAsyncProvideError +import com.itsaky.androidide.utils.DropHighlighter +import com.itsaky.androidide.utils.FileImporter +import com.unnamed.b.atv.model.TreeNode +import java.io.File + + +internal class FileTreeDropController( + private val activity: Activity, + private val onDropCompleted: (TreeNode?, File, Int) -> Unit, + private val onDropFailed: (String) -> Unit, +) { + + private data class DropTarget(val node: TreeNode?, val file: File) + + val nodeBinder = FileTreeViewHolder.ExternalDropHandler { node, file, view -> + bindDropTarget(view, DropTarget(node, file)) + } + + fun bindRootTarget(containerView: View, projectRootDirectory: File) { + bindDropTarget(containerView, DropTarget(null, projectRootDirectory)) + } + + private fun bindDropTarget(view: View, target: DropTarget) { + val dropCallback = object : DropTargetCallback { + override fun canAcceptDrop(event: DragEvent): Boolean { + return event.hasImportableContent(activity) + } + + override fun onDragEntered(view: View) { + DropHighlighter.highlight(view, activity) + } + + override fun onDragExited(view: View) { + DropHighlighter.clear(view) + } + + override fun onDrop(event: DragEvent): Boolean { + return importDroppedFiles(target, event) + } + } + + view.setOnDragListener(DragEventRouter(dropCallback)) + } + + private fun importDroppedFiles(target: DropTarget, event: DragEvent): Boolean { + val context = activity.applicationContext + val clipData = event.clipData ?: return false + val dragPermissions = activity.requestDragAndDropPermissions(event) + + executeAsyncProvideError({ + try { + FileImporter(context).copyDroppedFiles(clipData, target.file) + } finally { + dragPermissions?.release() + } + }) { result, error -> handleImportResult(target, result, error) } + + return true + } + + private fun handleImportResult( + target: DropTarget, + result: FileImporter.ImportResult?, + error: Throwable? + ) { + if (activity.isFinishing || activity.isDestroyed) return + + if (error != null) { + onDropFailed(error.toReadableMessage()) + return + } + + when (result) { + is FileImporter.ImportResult.Success -> handleSuccess(target, result) + is FileImporter.ImportResult.PartialSuccess -> handlePartialSuccess(target, result) + is FileImporter.ImportResult.Failure -> onDropFailed(result.error.toReadableMessage()) + else -> {} + } + } + + private fun handleSuccess(target: DropTarget, result: FileImporter.ImportResult.Success) { + if (result.count > 0) { + onDropCompleted(target.node, target.file, result.count) + } else { + val noFilesMsg = activity.getString(R.string.msg_file_tree_drop_no_files) + onDropFailed(noFilesMsg) + } + } + + private fun handlePartialSuccess(target: DropTarget, result: FileImporter.ImportResult.PartialSuccess) { + onDropCompleted(target.node, target.file, result.count) + onDropFailed(result.error.toReadableMessage()) + } + + private fun Throwable.toReadableMessage(): String { + return cause?.message + ?: message + ?: activity.getString(R.string.msg_file_tree_drop_import_failed) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt index 8e23f2ae48..2e99195fd9 100755 --- a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt @@ -29,8 +29,12 @@ import androidx.transition.ChangeBounds import androidx.transition.TransitionManager import com.blankj.utilcode.util.SizeUtils import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.itsaky.androidide.R import com.itsaky.androidide.adapters.viewholders.FileTreeViewHolder import com.itsaky.androidide.databinding.LayoutEditorFileTreeBinding +import com.itsaky.androidide.dnd.FileDragFailureReason +import com.itsaky.androidide.dnd.FileDragResult +import com.itsaky.androidide.dnd.FileDragStarter import com.itsaky.androidide.eventbus.events.filetree.FileClickEvent import com.itsaky.androidide.eventbus.events.filetree.FileLongClickEvent import com.itsaky.androidide.events.CollapseTreeNodeRequestEvent @@ -43,6 +47,8 @@ import com.itsaky.androidide.tasks.callables.FileTreeCallable import com.itsaky.androidide.tasks.callables.FileTreeCallable.SortFileName import com.itsaky.androidide.tasks.callables.FileTreeCallable.SortFolder import com.itsaky.androidide.utils.doOnApplyWindowInsets +import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.viewmodel.FileTreeViewModel import com.unnamed.b.atv.model.TreeNode import com.unnamed.b.atv.model.TreeNode.TreeNodeClickListener @@ -55,12 +61,30 @@ import java.io.File import java.util.Arrays class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener, - TreeNodeLongClickListener { + TreeNodeLongClickListener, TreeNode.TreeNodeDragListener { private var binding: LayoutEditorFileTreeBinding? = null private var fileTreeView: AndroidTreeView? = null private val viewModel by viewModels(ownerProducer = { requireActivity() }) + private val fileDragStarter by lazy(LazyThreadSafetyMode.NONE) { + FileDragStarter(requireContext()) + } + private var _dropController: FileTreeDropController? = null + private val dropController: FileTreeDropController + get() { + if (_dropController == null) { + _dropController = FileTreeDropController( + activity = requireActivity(), + onDropCompleted = ::onExternalDropCompleted, + onDropFailed = ::flashError, + ) + } + return _dropController!! + } + + private val externalDropHandler: FileTreeViewHolder.ExternalDropHandler + get() = dropController.nodeBinder override fun onCreateView( inflater: LayoutInflater, @@ -91,14 +115,15 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener, binding = null fileTreeView = null + _dropController = null } fun saveTreeState() { viewModel.saveState(fileTreeView) } - override fun onClick(node: TreeNode, p2: Any) { - val file = p2 as File + override fun onClick(node: TreeNode, value: Any) { + val file = value as File if (!file.exists()) { return } @@ -149,33 +174,40 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener, } } - private fun listNode(node: TreeNode, whenDone: Runnable) { + private fun listNode(node: TreeNode, onListed: () -> Unit) { + val safeContext = context ?: return + val safeDropHandler = externalDropHandler + node.children.clear() node.isExpanded = false executeAsync({ - listFilesForNode(node.value.listFiles() ?: return@executeAsync null, node) + listFilesForNode(node.value.listFiles() ?: return@executeAsync null, node, safeContext, safeDropHandler) var temp = node while (temp.size() == 1) { temp = temp.childAt(0) if (!temp.value.isDirectory) { break } - listFilesForNode(temp.value.listFiles() ?: continue, temp) + listFilesForNode(temp.value.listFiles() ?: continue, temp, safeContext, safeDropHandler) temp.isExpanded = true } null }) { - whenDone.run() + onListed() } } - private fun listFilesForNode(files: Array, parent: TreeNode) { + private fun listFilesForNode(files: Array, parent: TreeNode, context: Context, dropHandler: FileTreeViewHolder.ExternalDropHandler) { Arrays.sort(files, SortFileName()) Arrays.sort(files, SortFolder()) for (file in files) { - val node = TreeNode(file) - node.viewHolder = FileTreeViewHolder(context) - parent.addChild(node, false) + parent.addChild(createFileNode(file, context, dropHandler), false) + } + } + + private fun createFileNode(file: File, context: Context, dropHandler: FileTreeViewHolder.ExternalDropHandler): TreeNode { + return TreeNode(file).apply { + viewHolder = FileTreeViewHolder(context, dropHandler) } } @@ -187,6 +219,26 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener, return true } + override fun onStartDrag(node: TreeNode, value: Any) { + val file = value as? File ?: return + val sourceView = node.viewHolder?.view ?: return + + when (val result = fileDragStarter.startDrag(sourceView, file)) { + FileDragResult.Started -> Unit + + is FileDragResult.NotStarted -> { + val message = when (result.reason) { + FileDragFailureReason.FILE_NOT_FOUND -> getString(R.string.msg_file_tree_drag_file_not_found) + FileDragFailureReason.NOT_A_FILE -> getString(R.string.msg_file_tree_drag_not_a_file) + FileDragFailureReason.DRAG_NOT_STARTED -> getString(R.string.msg_file_tree_drag_failed) + } + flashError(message) + } + + is FileDragResult.Failed -> flashError(getString(R.string.msg_file_tree_drag_error)) + } + } + @Suppress("unused", "UNUSED_PARAMETER") @Subscribe(threadMode = MAIN) fun onGetListFilesRequested(event: ListProjectFilesRequestEvent?) { @@ -229,15 +281,15 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener, val projectDirPath = IProjectManager.getInstance().projectDirPath val projectDir = File(projectDirPath) val rootNode = TreeNode(File("")) - rootNode.viewHolder = FileTreeViewHolder(requireContext()) + rootNode.viewHolder = FileTreeViewHolder(requireContext(), externalDropHandler) val projectRoot = TreeNode.root(projectDir) - projectRoot.viewHolder = FileTreeViewHolder(context) + projectRoot.viewHolder = FileTreeViewHolder(context, externalDropHandler) rootNode.addChild(projectRoot, false) binding!!.horizontalCroll.visibility = View.GONE binding!!.horizontalCroll.visibility = View.VISIBLE - executeAsync(FileTreeCallable(context, projectRoot, projectDir)) { + executeAsync(FileTreeCallable(context, projectRoot, projectDir, externalDropHandler)) { if (binding == null) { // Fragment has been destroyed return@executeAsync @@ -252,6 +304,8 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener, binding!!.horizontalCroll.removeAllViews() val view = tree.view binding!!.horizontalCroll.addView(view) + dropController.bindRootTarget(binding!!.horizontalCroll, projectDir) + view.post { tryRestoreState(rootNode) } } } @@ -260,7 +314,10 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener, private fun createTreeView(node: TreeNode): AndroidTreeView? { return if (context == null) { null - } else AndroidTreeView(context, node, drawable.bg_ripple).also { fileTreeView = it } + } else AndroidTreeView(context, node, drawable.bg_ripple).also { + fileTreeView = it + it.setDefaultNodeDragListener(this) + } } private fun tryRestoreState(rootNode: TreeNode, state: String? = viewModel.savedState) { @@ -277,18 +334,45 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener, } private fun restoreNodeState(root: TreeNode, openNodes: Set) { - val children = root.children - var i = 0 - val childrenSize = children.size - while (i < childrenSize) { - val node = children[i] + for (node in root.children) { if (openNodes.contains(node.path)) { listNode(node) { expandNode(node, false) restoreNodeState(node, openNodes) } } - i++ + } + } + + private fun onExternalDropCompleted(targetNode: TreeNode?, targetFile: File, importedCount: Int) { + refreshNodeAfterDrop(targetNode, targetFile) + flashSuccess( + if (importedCount == 1) { + getString(R.string.msg_file_tree_drop_imported_single) + } else { + getString(R.string.msg_file_tree_drop_imported_multiple, importedCount) + } + ) + } + + private fun refreshNodeAfterDrop(targetNode: TreeNode?, targetFile: File) { + if (targetNode == null) { + listProjectFiles() + return + } + + if (targetFile.isDirectory) { + setLoading(targetNode) + listNode(targetNode) { expandNode(targetNode) } + return + } + + val parentNode = targetNode.parent + if (parentNode?.value?.isDirectory == true) { + setLoading(parentNode) + listNode(parentNode) { expandNode(parentNode) } + } else { + listProjectFiles() } } diff --git a/app/src/main/java/com/itsaky/androidide/tasks/callables/FileTreeCallable.java b/app/src/main/java/com/itsaky/androidide/tasks/callables/FileTreeCallable.java index a57d0cc885..986e7f09e1 100755 --- a/app/src/main/java/com/itsaky/androidide/tasks/callables/FileTreeCallable.java +++ b/app/src/main/java/com/itsaky/androidide/tasks/callables/FileTreeCallable.java @@ -33,29 +33,42 @@ public class FileTreeCallable implements Callable { private final Context ctx; private final TreeNode parent; private final File file; + private final FileTreeViewHolder.ExternalDropHandler externalDropHandler; public FileTreeCallable(Context ctx, TreeNode parent, File file) { + this(ctx, parent, file, null); + } + + public FileTreeCallable( + Context ctx, TreeNode parent, File file, FileTreeViewHolder.ExternalDropHandler externalDropHandler) { this.ctx = ctx; this.parent = parent; this.file = file; + this.externalDropHandler = externalDropHandler; } @Override public Boolean call() throws Exception { - getNodeFromArray(file.listFiles(/*new HiddenFilesFilter()*/ ), parent); + populateChildren(file.listFiles(/*new HiddenFilesFilter()*/ ), parent); return true; } - private void getNodeFromArray(File[] files, TreeNode parent) { + private void populateChildren(File[] files, TreeNode parent) { + if (files == null) return; + Arrays.sort(files, new SortFileName()); Arrays.sort(files, new SortFolder()); for (File file : files) { - TreeNode node = new TreeNode(file); - node.setViewHolder(new FileTreeViewHolder(ctx)); - parent.addChild(node, false); + parent.addChild(createNode(file), false); } } + private TreeNode createNode(File file) { + TreeNode node = new TreeNode(file); + node.setViewHolder(new FileTreeViewHolder(ctx, externalDropHandler)); + return node; + } + public static class HiddenFilesFilter implements FileFilter { @Override diff --git a/app/src/main/java/com/itsaky/androidide/utils/FileImporter.kt b/app/src/main/java/com/itsaky/androidide/utils/FileImporter.kt new file mode 100644 index 0000000000..f531f3fa1a --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/FileImporter.kt @@ -0,0 +1,92 @@ +package com.itsaky.androidide.utils + +import android.content.ClipData +import android.content.Context +import com.itsaky.androidide.R +import com.itsaky.androidide.dnd.toImportableExternalUri +import java.io.File + +/** + * Utility class responsible for safely copying external files dropped into the IDE. + */ +class FileImporter(private val context: Context) { + + sealed interface ImportResult { + data class Success(val count: Int) : ImportResult + data class PartialSuccess(val count: Int, val error: Throwable) : ImportResult + data class Failure(val error: Throwable) : ImportResult + } + + /** + * Iterates through the provided [clipData] and copies the valid external files into the [targetFile] directory. + * * @return An [ImportResult] indicating the success, partial success, or failure of the operation. + */ + fun copyDroppedFiles(clipData: ClipData, targetFile: File): ImportResult { + val targetDirectory = resolveTargetDirectory(targetFile) + require(targetDirectory.exists() && targetDirectory.isDirectory) { + context.getString(R.string.msg_file_tree_drop_destination_missing) + } + + val validUris = (0 until clipData.itemCount) + .mapNotNull { clipData.getItemAt(it).toImportableExternalUri(context) } + + if (validUris.isEmpty()) { + return ImportResult.Failure(IllegalArgumentException("No importable files found")) + } + + val results = validUris.map { uri -> + runCatching { + val defaultName = context.getString(R.string.msg_file_tree_drop_default_name) + val rawName = uri.getFileName(context).ifBlank { defaultName } + var sanitizedName = rawName.substringAfterLast('/').substringAfterLast('\\') + + if (sanitizedName == "." || sanitizedName == ".." || sanitizedName.isBlank()) { + sanitizedName = defaultName + } + + val destinationFile = createAvailableTargetFile(targetDirectory, sanitizedName) + + UriFileImporter.copyUriToFile( + context = context, + uri = uri, + destinationFile = destinationFile, + onOpenFailed = { IllegalStateException("Unable to open URI: $sanitizedName") } + ) + } + } + + val successes = results.count { it.isSuccess } + val errors = results.mapNotNull { it.exceptionOrNull() } + + return when { + errors.isEmpty() -> ImportResult.Success(successes) + successes > 0 -> ImportResult.PartialSuccess(successes, errors.first()) + else -> ImportResult.Failure(errors.first()) + } + } + + private fun resolveTargetDirectory(targetFile: File): File { + return if (targetFile.isDirectory) { + targetFile + } else { + targetFile.parentFile + ?: error(context.getString(R.string.msg_file_tree_drop_destination_unresolved)) + } + } + + private fun createAvailableTargetFile(directory: File, originalName: String): File { + val dotIndex = originalName.lastIndexOf('.') + val hasExtension = dotIndex > 0 && dotIndex < originalName.lastIndex + val baseName = if (hasExtension) originalName.take(dotIndex) else originalName + val extension = if (hasExtension) originalName.substring(dotIndex) else "" + + var candidate = File(directory, originalName) + var suffix = 1 + while (candidate.exists()) { + candidate = File(directory, "$baseName ($suffix)$extension") + suffix++ + } + + return candidate + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/UriFileImporter.kt b/app/src/main/java/com/itsaky/androidide/utils/UriFileImporter.kt new file mode 100644 index 0000000000..c834071101 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/UriFileImporter.kt @@ -0,0 +1,66 @@ +package com.itsaky.androidide.utils + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log +import java.io.File + +object UriFileImporter { + const val TAG = "UriFileImporter" + + @JvmStatic + fun copyUriToFile( + context: Context, + uri: Uri, + destinationFile: File, + onOpenFailed: (() -> Throwable)? = null, + ) { + copyUriToFile( + contentResolver = context.contentResolver, + uri = uri, + destinationFile = destinationFile, + onOpenFailed = onOpenFailed, + ) + } + + @JvmStatic + fun copyUriToFile( + contentResolver: ContentResolver, + uri: Uri, + destinationFile: File, + onOpenFailed: (() -> Throwable)? = null, + ) { + contentResolver.openInputStream(uri)?.use { input -> + destinationFile.outputStream().use { output -> + input.copyTo(output) + } + } ?: throw (onOpenFailed?.invoke() ?: IllegalStateException("Unable to open URI: $uri")) + } + + @JvmStatic + fun getDisplayName(contentResolver: ContentResolver, uri: Uri): String? { + return try { + when (uri.scheme) { + "content" -> queryDisplayName(contentResolver, uri) ?: uri.lastPathSegment + "file" -> uri.lastPathSegment + else -> uri.lastPathSegment + } + } catch (e: Exception) { + Log.e(TAG, "Error getting filename from URI", e) + uri.lastPathSegment + } + } + + private fun queryDisplayName(contentResolver: ContentResolver, uri: Uri): String? { + return contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1 && cursor.moveToFirst()) { + cursor.getString(nameIndex) + } else { + null + } + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt index fb237edbf6..3d9289464b 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -13,6 +13,7 @@ import com.itsaky.androidide.ui.models.PluginManagerUiEffect import com.itsaky.androidide.ui.models.PluginManagerUiEvent import com.itsaky.androidide.ui.models.PluginManagerUiState import com.itsaky.androidide.ui.models.PluginOperation +import com.itsaky.androidide.utils.UriFileImporter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -23,7 +24,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -import java.io.FileOutputStream /** * ViewModel for the Plugin Manager screen @@ -238,19 +238,14 @@ class PluginManagerViewModel( try { tempFile = withContext(Dispatchers.IO) { - val inputStream = contentResolver.openInputStream(uri) - ?: throw Exception("Cannot open file") - - val fileName = getFileNameFromUri(uri) + val fileName = UriFileImporter.getDisplayName(contentResolver, uri) val extension = if (fileName?.endsWith(".cgp", ignoreCase = true) == true) ".cgp" else ".apk" val tempFileName = "temp_plugin_${System.currentTimeMillis()}$extension" val tempDir = File(filesDir, "temp").apply { mkdirs() } val tempFile = File(tempDir, tempFileName) - FileOutputStream(tempFile).use { output -> - inputStream.use { input -> - input.copyTo(output) - } + UriFileImporter.copyUriToFile(contentResolver, uri, tempFile) { + Exception("Cannot open file") } tempFile } @@ -275,7 +270,6 @@ class PluginManagerViewModel( ) ) } - } catch (exception: Exception) { Log.e(TAG, "Error installing plugin from URI", exception) _uiEffect.trySend( @@ -347,28 +341,4 @@ class PluginManagerViewModel( } } - /** - * Get the actual filename from a content URI - */ - private fun getFileNameFromUri(uri: Uri): String? { - return try { - when (uri.scheme) { - "content" -> { - contentResolver.query(uri, null, null, null, null)?.use { cursor -> - val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) - if (nameIndex != -1 && cursor.moveToFirst()) { - cursor.getString(nameIndex) - } else { - null - } - } - } - "file" -> uri.lastPathSegment - else -> uri.lastPathSegment - } - } catch (e: Exception) { - Log.e(TAG, "Error getting filename from URI", e) - null - } - } -} \ No newline at end of file +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index ec118b2699..f5066ae99c 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -172,6 +172,9 @@ Java version Failed to start dragging the file Error preparing file for drag + File not found + Only files can be dragged + When you close the project, all build tasks will be stopped and unsaved changes to your project file may be lost. Do you want to save the files before you close the project? Basic Activity diff --git a/treeview/src/main/java/com/unnamed/b/atv/model/TreeNode.java b/treeview/src/main/java/com/unnamed/b/atv/model/TreeNode.java index fa0eaef375..d273d0071c 100755 --- a/treeview/src/main/java/com/unnamed/b/atv/model/TreeNode.java +++ b/treeview/src/main/java/com/unnamed/b/atv/model/TreeNode.java @@ -31,6 +31,7 @@ public class TreeNode { private File mValue; private boolean mExpanded; private boolean mIsDirectory; + private TreeNodeDragListener mDragListener; public TreeNode(File value) { children = Collections.synchronizedList(new ArrayList()); @@ -216,6 +217,15 @@ public TreeNode setLongClickListener(TreeNodeLongClickListener listener) { return this; } + public TreeNodeDragListener getDragListener() { + return mDragListener; + } + + public TreeNode setDragListener(TreeNodeDragListener listener) { + mDragListener = listener; + return this; + } + public BaseNodeViewHolder getViewHolder() { return mViewHolder; } @@ -332,4 +342,8 @@ public interface TreeNodeClickListener { public interface TreeNodeLongClickListener { boolean onLongClick(TreeNode node, Object value); } + + public interface TreeNodeDragListener { + void onStartDrag(TreeNode node, Object value); + } } diff --git a/treeview/src/main/java/com/unnamed/b/atv/view/AndroidTreeView.java b/treeview/src/main/java/com/unnamed/b/atv/view/AndroidTreeView.java index 5d922f36a4..2a217ab3c6 100755 --- a/treeview/src/main/java/com/unnamed/b/atv/view/AndroidTreeView.java +++ b/treeview/src/main/java/com/unnamed/b/atv/view/AndroidTreeView.java @@ -1,5 +1,6 @@ package com.unnamed.b.atv.view; +import android.annotation.SuppressLint; import android.content.Context; import android.text.TextUtils; import android.view.ContextThemeWrapper; @@ -35,6 +36,7 @@ public class AndroidTreeView { private TreeNode.BaseNodeViewHolder defaultViewHolder; private TreeNode.TreeNodeClickListener nodeClickListener; private TreeNode.TreeNodeLongClickListener nodeLongClickListener; + private TreeNode.TreeNodeDragListener nodeDragListener; private boolean mSelectionModeEnabled; private boolean use2dScroll = false; private boolean enableAutoToggle = true; @@ -143,6 +145,10 @@ public void setDefaultNodeLongClickListener(TreeNode.TreeNodeLongClickListener l nodeLongClickListener = listener; } + public void setDefaultNodeDragListener(TreeNode.TreeNodeDragListener listener) { + this.nodeDragListener = listener; + } + public void expandAll() { expandNode(mRoot, true); } @@ -402,6 +408,7 @@ private void getSaveState(TreeNode root, StringBuilder sBuilder) { } } + @SuppressLint("ClickableViewAccessibility") private void addNode(ViewGroup container, final TreeNode n) { final TreeNode.BaseNodeViewHolder viewHolder = getViewHolderForNode(n); final View nodeView = viewHolder.getView(); @@ -414,30 +421,9 @@ private void addNode(ViewGroup container, final TreeNode n) { viewHolder.toggleSelectionMode(true); } - nodeView.setOnClickListener( - v -> { - if (n.getClickListener() != null) { - n.getClickListener().onClick(n, n.getValue()); - } else if (nodeClickListener != null) { - nodeClickListener.onClick(n, n.getValue()); - } - if (enableAutoToggle) { - toggleNode(n); - } - }); - - nodeView.setOnLongClickListener( - view -> { - if (n.getLongClickListener() != null) { - return n.getLongClickListener().onLongClick(n, n.getValue()); - } else if (nodeLongClickListener != null) { - return nodeLongClickListener.onLongClick(n, n.getValue()); - } - if (enableAutoToggle) { - toggleNode(n); - } - return false; - }); + nodeView.setOnClickListener(v -> handleNodeClick(n)); + nodeView.setOnLongClickListener(v -> handleNodeLongClick(n)); + nodeView.setOnTouchListener(new NodeTouchHandler(n, nodeView, nodeDragListener)); } private void toggleSelectionMode(TreeNode parent, boolean mSelectionModeEnabled) { @@ -451,6 +437,19 @@ private void toggleSelectionMode(TreeNode parent, boolean mSelectionModeEnabled) } } + private void handleNodeClick(TreeNode n) { + if (n.getClickListener() != null) n.getClickListener().onClick(n, n.getValue()); + else if (nodeClickListener != null) nodeClickListener.onClick(n, n.getValue()); + if (enableAutoToggle) toggleNode(n); + } + + private boolean handleNodeLongClick(TreeNode n) { + if (n.getLongClickListener() != null) return n.getLongClickListener().onLongClick(n, n.getValue()); + if (nodeLongClickListener != null) return nodeLongClickListener.onLongClick(n, n.getValue()); + if (enableAutoToggle) toggleNode(n); + return false; + } + // TODO Do we need to go through whole tree? Save references or consider collapsed nodes as not // selected private List getSelected(TreeNode parent) { diff --git a/treeview/src/main/java/com/unnamed/b/atv/view/NodeTouchHandler.java b/treeview/src/main/java/com/unnamed/b/atv/view/NodeTouchHandler.java new file mode 100644 index 0000000000..6d3a3e9f99 --- /dev/null +++ b/treeview/src/main/java/com/unnamed/b/atv/view/NodeTouchHandler.java @@ -0,0 +1,101 @@ +package com.unnamed.b.atv.view; + +import android.annotation.SuppressLint; +import android.view.GestureDetector; +import android.view.HapticFeedbackConstants; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; + +import com.unnamed.b.atv.model.TreeNode; + +class NodeTouchHandler implements View.OnTouchListener { + + private final TreeNode node; + private final View view; + private final TreeNode.TreeNodeDragListener defaultDragListener; + private final GestureDetector gestureDetector; + + private boolean isAwaitingDrag = false; + + NodeTouchHandler(TreeNode node, View view, TreeNode.TreeNodeDragListener defaultDragListener) { + this.node = node; + this.view = view; + this.defaultDragListener = defaultDragListener; + + this.gestureDetector = new GestureDetector(view.getContext(), new GestureListener()); + this.gestureDetector.setIsLongpressEnabled(true); + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouch(View v, MotionEvent event) { + gestureDetector.onTouchEvent(event); + + switch (event.getAction()) { + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + resetTouchState(); + break; + + case MotionEvent.ACTION_MOVE: + handleMove(); + break; + } + + return true; + } + + private void resetTouchState() { + isAwaitingDrag = false; + view.setPressed(false); + } + + private void handleMove() { + if (!isAwaitingDrag) return; + + isAwaitingDrag = false; + dispatchDrag(); + } + + private void dispatchDrag() { + TreeNode.TreeNodeDragListener listener = node.getDragListener() != null + ? node.getDragListener() + : defaultDragListener; + + if (listener != null) { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + listener.onStartDrag(node, node.getValue()); + } + } + + private class GestureListener extends GestureDetector.SimpleOnGestureListener { + @Override + public boolean onDown(@NonNull MotionEvent e) { + view.setPressed(true); + return true; + } + + @Override + public boolean onSingleTapConfirmed(@NonNull MotionEvent e) { + view.performClick(); + return true; + } + + @Override + public void onLongPress(@NonNull MotionEvent e) { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + view.performLongClick(); + } + + @Override + public boolean onDoubleTapEvent(MotionEvent e) { + if (e.getAction() == MotionEvent.ACTION_DOWN) { + isAwaitingDrag = true; + return true; + } + return super.onDoubleTapEvent(e); + } + } +} From ad657d650344edaf41415c7672c63b5fbcb40a24 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 2 Apr 2026 09:32:27 -0500 Subject: [PATCH 2/4] fix(dnd): improve external URI parsing and file import safety --- .../androidide/dnd/DragAndDropExtensions.kt | 24 +++++++++++-------- .../androidide/dnd/DropTargetCallback.kt | 2 +- .../itsaky/androidide/utils/FileImporter.kt | 10 ++++---- .../androidide/utils/UriFileImporter.kt | 18 ++++++++++---- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt b/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt index f1692388a0..10e55a5a80 100644 --- a/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt +++ b/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt @@ -17,7 +17,7 @@ fun DragEvent.hasImportableContent(context: Context): Boolean { DragEvent.ACTION_DROP -> { val clip = clipData ?: return false (0 until clip.itemCount).any { index -> - clip.getItemAt(index).toImportableExternalUri(context) != null + clip.getItemAt(index).toImportableExternalUris(context).isNotEmpty() } } else -> clipDescription?.hasImportableMimeType() == true @@ -25,22 +25,26 @@ fun DragEvent.hasImportableContent(context: Context): Boolean { } /** - * Resolves the [ClipData.Item] to an external [Uri], ignoring internal application URIs. + * Resolves the [ClipData.Item] to a list of external [Uri]s, ignoring internal application URIs. */ -fun ClipData.Item.toImportableExternalUri(context: Context): Uri? { - val resolvedUri = toExternalUri() ?: return null - return resolvedUri.takeUnless { it.isInternalDragUri(context) } +fun ClipData.Item.toImportableExternalUris(context: Context): List { + return toExternalUris().filterNot { it.isInternalDragUri(context) } } private fun Uri.isInternalDragUri(context: Context): Boolean { return authority == "${context.packageName}.providers.fileprovider" } -private fun ClipData.Item.toExternalUri(): Uri? { - return uri - ?: text?.toString() - ?.takeIf { it.startsWith("content://") || it.startsWith("file://") } - ?.toUri() +private fun ClipData.Item.toExternalUris(): List { + uri?.let { return listOf(it) } + + val textContent = text?.toString() ?: return emptyList() + + return textContent.lineSequence() + .map { it.trim() } + .filter { it.startsWith("content://") || it.startsWith("file://") } + .map { it.toUri() } + .toList() } private fun ClipDescription.hasImportableMimeType(): Boolean { diff --git a/app/src/main/java/com/itsaky/androidide/dnd/DropTargetCallback.kt b/app/src/main/java/com/itsaky/androidide/dnd/DropTargetCallback.kt index dce6898c84..ba39ff0173 100644 --- a/app/src/main/java/com/itsaky/androidide/dnd/DropTargetCallback.kt +++ b/app/src/main/java/com/itsaky/androidide/dnd/DropTargetCallback.kt @@ -29,7 +29,7 @@ interface DropTargetCallback { /** * Called when the user successfully drops a valid item on the target. - * * @return True if the drop was successfully consumed and handled. + * @return True if the drop was successfully consumed and handled. */ fun onDrop(event: DragEvent): Boolean } diff --git a/app/src/main/java/com/itsaky/androidide/utils/FileImporter.kt b/app/src/main/java/com/itsaky/androidide/utils/FileImporter.kt index f531f3fa1a..95186e73fc 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/FileImporter.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/FileImporter.kt @@ -3,7 +3,7 @@ package com.itsaky.androidide.utils import android.content.ClipData import android.content.Context import com.itsaky.androidide.R -import com.itsaky.androidide.dnd.toImportableExternalUri +import com.itsaky.androidide.dnd.toImportableExternalUris import java.io.File /** @@ -28,10 +28,10 @@ class FileImporter(private val context: Context) { } val validUris = (0 until clipData.itemCount) - .mapNotNull { clipData.getItemAt(it).toImportableExternalUri(context) } + .flatMap { clipData.getItemAt(it).toImportableExternalUris(context) } if (validUris.isEmpty()) { - return ImportResult.Failure(IllegalArgumentException("No importable files found")) + return ImportResult.Failure(IllegalArgumentException(context.getString(R.string.msg_file_tree_drop_no_files))) } val results = validUris.map { uri -> @@ -50,7 +50,9 @@ class FileImporter(private val context: Context) { context = context, uri = uri, destinationFile = destinationFile, - onOpenFailed = { IllegalStateException("Unable to open URI: $sanitizedName") } + onOpenFailed = { IllegalStateException( + context.getString(R.string.msg_file_tree_drop_read_failed, sanitizedName) + )} ) } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/UriFileImporter.kt b/app/src/main/java/com/itsaky/androidide/utils/UriFileImporter.kt index c834071101..283273fa8f 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/UriFileImporter.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/UriFileImporter.kt @@ -32,11 +32,21 @@ object UriFileImporter { destinationFile: File, onOpenFailed: (() -> Throwable)? = null, ) { - contentResolver.openInputStream(uri)?.use { input -> - destinationFile.outputStream().use { output -> - input.copyTo(output) + val inputStream = contentResolver.openInputStream(uri) + ?: throw (onOpenFailed?.invoke() ?: IllegalStateException("Unable to open URI: $uri")) + + try { + inputStream.use { input -> + destinationFile.outputStream().use { output -> + input.copyTo(output) + } + } + } catch (e: Exception) { + if (destinationFile.exists()) { + destinationFile.delete() } - } ?: throw (onOpenFailed?.invoke() ?: IllegalStateException("Unable to open URI: $uri")) + throw e + } } @JvmStatic From 32c49a70cbba49365ae984411375107d7e54e847 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 2 Apr 2026 16:53:30 -0500 Subject: [PATCH 3/4] refactor(dnd): modernize drag-and-drop logic and migrate to coroutines --- .../androidide/dnd/DragAndDropExtensions.kt | 3 +- .../itsaky/androidide/dnd/FileDragStarter.kt | 37 ++++++++----------- .../sidebar/FileTreeDropController.kt | 32 ++++++++++++---- .../fragments/sidebar/FileTreeFragment.kt | 15 ++++---- 4 files changed, 49 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt b/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt index 10e55a5a80..2dce2f381f 100644 --- a/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt +++ b/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.dnd import android.content.ClipData import android.content.ClipDescription +import android.content.ContentResolver import android.content.Context import android.net.Uri import android.view.DragEvent @@ -42,8 +43,8 @@ private fun ClipData.Item.toExternalUris(): List { return textContent.lineSequence() .map { it.trim() } - .filter { it.startsWith("content://") || it.startsWith("file://") } .map { it.toUri() } + .filter { it.scheme == ContentResolver.SCHEME_CONTENT || it.scheme == ContentResolver.SCHEME_FILE } .toList() } diff --git a/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt b/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt index 953ffef496..cb30e0c79f 100644 --- a/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt +++ b/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt @@ -12,14 +12,14 @@ import java.util.Locale sealed interface FileDragResult { data object Started : FileDragResult - data class NotStarted(val reason: FileDragFailureReason) : FileDragResult - data class Failed(val throwable: Throwable? = null) : FileDragResult + data class Failed(val error: FileDragError) : FileDragResult } -enum class FileDragFailureReason { - FILE_NOT_FOUND, - NOT_A_FILE, - DRAG_NOT_STARTED, +sealed interface FileDragError { + data object FileNotFound : FileDragError + data object NotAFile : FileDragError + data object SystemRejected : FileDragError + data class Exception(val throwable: Throwable) : FileDragError } class FileDragStarter( @@ -28,11 +28,11 @@ class FileDragStarter( fun startDrag(sourceView: View, file: File): FileDragResult { if (!file.exists()) { - return FileDragResult.NotStarted(FileDragFailureReason.FILE_NOT_FOUND) + return FileDragResult.Failed(FileDragError.FileNotFound) } if (!file.isFile) { - return FileDragResult.NotStarted(FileDragFailureReason.NOT_A_FILE) + return FileDragResult.Failed(FileDragError.NotAFile) } return runCatching { @@ -49,8 +49,13 @@ class FileDragStarter( DRAG_FLAGS, ) }.fold( - onSuccess = ::toDragResult, - onFailure = ::toFailureResult, + onSuccess = { started -> + if (started) FileDragResult.Started + else FileDragResult.Failed(FileDragError.SystemRejected) + }, + onFailure = { throwable -> + FileDragResult.Failed(FileDragError.Exception(throwable)) + }, ) } @@ -77,18 +82,6 @@ class FileDragStarter( ) } - private fun toDragResult(started: Boolean): FileDragResult { - if (started) { - return FileDragResult.Started - } - - return FileDragResult.NotStarted(FileDragFailureReason.DRAG_NOT_STARTED) - } - - private fun toFailureResult(throwable: Throwable): FileDragResult { - return FileDragResult.Failed(throwable) - } - private val fileProviderAuthority: String get() = "${context.packageName}.providers.fileprovider" diff --git a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeDropController.kt b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeDropController.kt index fb0bb25a7b..770836ae39 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeDropController.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeDropController.kt @@ -3,15 +3,19 @@ package com.itsaky.androidide.fragments.sidebar import android.app.Activity import android.view.DragEvent import android.view.View +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope import com.itsaky.androidide.R import com.itsaky.androidide.adapters.viewholders.FileTreeViewHolder import com.itsaky.androidide.dnd.DragEventRouter import com.itsaky.androidide.dnd.DropTargetCallback import com.itsaky.androidide.dnd.hasImportableContent -import com.itsaky.androidide.tasks.executeAsyncProvideError import com.itsaky.androidide.utils.DropHighlighter import com.itsaky.androidide.utils.FileImporter import com.unnamed.b.atv.model.TreeNode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File @@ -58,13 +62,27 @@ internal class FileTreeDropController( val clipData = event.clipData ?: return false val dragPermissions = activity.requestDragAndDropPermissions(event) - executeAsyncProvideError({ - try { - FileImporter(context).copyDroppedFiles(clipData, target.file) - } finally { - dragPermissions?.release() + val lifecycleOwner = activity as? LifecycleOwner + if (lifecycleOwner == null) { + dragPermissions?.release() + return false + } + + lifecycleOwner.lifecycleScope.launch { + val result = runCatching { + withContext(Dispatchers.IO) { + FileImporter(context).copyDroppedFiles(clipData, target.file) + } } - }) { result, error -> handleImportResult(target, result, error) } + + dragPermissions?.release() + + handleImportResult( + target = target, + result = result.getOrNull(), + error = result.exceptionOrNull() + ) + } return true } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt index 2e99195fd9..66da4b00ed 100755 --- a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt @@ -32,7 +32,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.itsaky.androidide.R import com.itsaky.androidide.adapters.viewholders.FileTreeViewHolder import com.itsaky.androidide.databinding.LayoutEditorFileTreeBinding -import com.itsaky.androidide.dnd.FileDragFailureReason +import com.itsaky.androidide.dnd.FileDragError import com.itsaky.androidide.dnd.FileDragResult import com.itsaky.androidide.dnd.FileDragStarter import com.itsaky.androidide.eventbus.events.filetree.FileClickEvent @@ -226,16 +226,15 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener, when (val result = fileDragStarter.startDrag(sourceView, file)) { FileDragResult.Started -> Unit - is FileDragResult.NotStarted -> { - val message = when (result.reason) { - FileDragFailureReason.FILE_NOT_FOUND -> getString(R.string.msg_file_tree_drag_file_not_found) - FileDragFailureReason.NOT_A_FILE -> getString(R.string.msg_file_tree_drag_not_a_file) - FileDragFailureReason.DRAG_NOT_STARTED -> getString(R.string.msg_file_tree_drag_failed) + is FileDragResult.Failed -> { + val message = when (result.error) { + FileDragError.FileNotFound -> getString(R.string.msg_file_tree_drag_file_not_found) + FileDragError.NotAFile -> getString(R.string.msg_file_tree_drag_not_a_file) + FileDragError.SystemRejected -> getString(R.string.msg_file_tree_drag_failed) + is FileDragError.Exception -> getString(R.string.msg_file_tree_drag_error) } flashError(message) } - - is FileDragResult.Failed -> flashError(getString(R.string.msg_file_tree_drag_error)) } } From 65a14f3018df5325d3d889f713c92342d211b632 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 2 Apr 2026 16:58:41 -0500 Subject: [PATCH 4/4] refactor: update indentation from 2 spaces to 4 spaces --- .../viewholders/FileTreeViewHolder.java | 141 +-- .../androidide/dnd/DragAndDropExtensions.kt | 5 +- .../sidebar/FileTreeDropController.kt | 169 ++-- .../fragments/sidebar/FileTreeFragment.kt | 579 +++++------ .../tasks/callables/FileTreeCallable.java | 98 +- .../itsaky/androidide/utils/FileImporter.kt | 11 +- .../androidide/utils/UriFileImporter.kt | 112 +-- .../viewmodels/PluginManagerViewModel.kt | 12 +- .../com/unnamed/b/atv/model/TreeNode.java | 554 +++++------ .../unnamed/b/atv/view/AndroidTreeView.java | 928 +++++++++--------- .../unnamed/b/atv/view/NodeTouchHandler.java | 4 +- 11 files changed, 1335 insertions(+), 1278 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/adapters/viewholders/FileTreeViewHolder.java b/app/src/main/java/com/itsaky/androidide/adapters/viewholders/FileTreeViewHolder.java index 069916f7b3..041161f5f8 100755 --- a/app/src/main/java/com/itsaky/androidide/adapters/viewholders/FileTreeViewHolder.java +++ b/app/src/main/java/com/itsaky/androidide/adapters/viewholders/FileTreeViewHolder.java @@ -24,100 +24,103 @@ import android.view.LayoutInflater; import android.view.View; import android.widget.LinearLayout; + import androidx.transition.ChangeImageTransform; import androidx.transition.TransitionManager; + import com.blankj.utilcode.util.SizeUtils; import com.itsaky.androidide.databinding.LayoutFiletreeItemBinding; import com.itsaky.androidide.models.FileExtension; import com.itsaky.androidide.resources.R; import com.unnamed.b.atv.model.TreeNode; + import java.io.File; public class FileTreeViewHolder extends TreeNode.BaseNodeViewHolder { - public interface ExternalDropHandler { - void onNodeBound(TreeNode node, File file, View view); - } + public interface ExternalDropHandler { + void onNodeBound(TreeNode node, File file, View view); + } + + private LayoutFiletreeItemBinding binding; + private final ExternalDropHandler externalDropHandler; + + public FileTreeViewHolder(Context context) { + this(context, null); + } - private LayoutFiletreeItemBinding binding; - private final ExternalDropHandler externalDropHandler; + public FileTreeViewHolder(Context context, ExternalDropHandler externalDropHandler) { + super(context); + this.externalDropHandler = externalDropHandler; + } - public FileTreeViewHolder(Context context) { - this(context, null); - } + @Override + public View createNodeView(TreeNode node, File file) { + this.binding = LayoutFiletreeItemBinding.inflate(LayoutInflater.from(context)); - public FileTreeViewHolder(Context context, ExternalDropHandler externalDropHandler) { - super(context); - this.externalDropHandler = externalDropHandler; - } + final var dp15 = SizeUtils.dp2px(15); + final boolean isDir = node.isDirectory(); + final var icon = getIconForFile(file, isDir); + final var chevron = binding.filetreeChevron; + binding.filetreeName.setText(file.getName()); + binding.filetreeIcon.setImageResource(icon); - @Override - public View createNodeView(TreeNode node, File file) { - this.binding = LayoutFiletreeItemBinding.inflate(LayoutInflater.from(context)); + final var root = applyPadding(node, binding, dp15); - final var dp15 = SizeUtils.dp2px(15); - final boolean isDir = node.isDirectory(); - final var icon = getIconForFile(file, isDir); - final var chevron = binding.filetreeChevron; - binding.filetreeName.setText(file.getName()); - binding.filetreeIcon.setImageResource(icon); + if (isDir) { + chevron.setVisibility(View.VISIBLE); + updateChevronIcon(node.isExpanded()); + } else { + chevron.setVisibility(View.INVISIBLE); + } - final var root = applyPadding(node, binding, dp15); + if (externalDropHandler != null) { + externalDropHandler.onNodeBound(node, file, root); + } - if (isDir) { - chevron.setVisibility(View.VISIBLE); - updateChevronIcon(node.isExpanded()); - } else { - chevron.setVisibility(View.INVISIBLE); + return root; } - if (externalDropHandler != null) { - externalDropHandler.onNodeBound(node, file, root); + private void updateChevronIcon(boolean expanded) { + final int chevronIcon; + if (expanded) { + chevronIcon = R.drawable.ic_chevron_down; + } else { + chevronIcon = R.drawable.ic_chevron_right; + } + + TransitionManager.beginDelayedTransition(binding.getRoot(), new ChangeImageTransform()); + binding.filetreeChevron.setImageResource(chevronIcon); } - return root; - } + protected LinearLayout applyPadding( + final TreeNode node, final LayoutFiletreeItemBinding binding, final int padding) { + final var root = binding.getRoot(); + root.setPaddingRelative( + root.getPaddingLeft() + (padding * (node.getLevel() - 1)), + root.getPaddingTop(), + root.getPaddingRight(), + root.getPaddingBottom()); + return root; + } - private void updateChevronIcon(boolean expanded) { - final int chevronIcon; - if (expanded) { - chevronIcon = R.drawable.ic_chevron_down; - } else { - chevronIcon = R.drawable.ic_chevron_right; + protected int getIconForFile(final File file, boolean isDirectory) { + return FileExtension.Factory.forFile(file, isDirectory).getIcon(); } - TransitionManager.beginDelayedTransition(binding.getRoot(), new ChangeImageTransform()); - binding.filetreeChevron.setImageResource(chevronIcon); - } - - protected LinearLayout applyPadding( - final TreeNode node, final LayoutFiletreeItemBinding binding, final int padding) { - final var root = binding.getRoot(); - root.setPaddingRelative( - root.getPaddingLeft() + (padding * (node.getLevel() - 1)), - root.getPaddingTop(), - root.getPaddingRight(), - root.getPaddingBottom()); - return root; - } - - protected int getIconForFile(final File file, boolean isDirectory) { - return FileExtension.Factory.forFile(file, isDirectory).getIcon(); - } - - public void updateChevron(boolean expanded) { - setLoading(false); - updateChevronIcon(expanded); - } - - public void setLoading(boolean loading) { - final int viewIndex; - if (loading) { - viewIndex = 1; - } else { - viewIndex = 0; + public void updateChevron(boolean expanded) { + setLoading(false); + updateChevronIcon(expanded); } - binding.chevronLoadingSwitcher.setDisplayedChild(viewIndex); - } + public void setLoading(boolean loading) { + final int viewIndex; + if (loading) { + viewIndex = 1; + } else { + viewIndex = 0; + } + + binding.chevronLoadingSwitcher.setDisplayedChild(viewIndex); + } } diff --git a/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt b/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt index 2dce2f381f..3d27258387 100644 --- a/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt +++ b/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt @@ -21,6 +21,7 @@ fun DragEvent.hasImportableContent(context: Context): Boolean { clip.getItemAt(index).toImportableExternalUris(context).isNotEmpty() } } + else -> clipDescription?.hasImportableMimeType() == true } } @@ -50,6 +51,6 @@ private fun ClipData.Item.toExternalUris(): List { private fun ClipDescription.hasImportableMimeType(): Boolean { return hasMimeType(ClipDescription.MIMETYPE_TEXT_URILIST) || - hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) || - hasMimeType("*/*") + hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) || + hasMimeType("*/*") } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeDropController.kt b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeDropController.kt index 770836ae39..43a182626a 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeDropController.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeDropController.kt @@ -20,110 +20,113 @@ import java.io.File internal class FileTreeDropController( - private val activity: Activity, - private val onDropCompleted: (TreeNode?, File, Int) -> Unit, - private val onDropFailed: (String) -> Unit, + private val activity: Activity, + private val onDropCompleted: (TreeNode?, File, Int) -> Unit, + private val onDropFailed: (String) -> Unit, ) { - private data class DropTarget(val node: TreeNode?, val file: File) + private data class DropTarget(val node: TreeNode?, val file: File) - val nodeBinder = FileTreeViewHolder.ExternalDropHandler { node, file, view -> - bindDropTarget(view, DropTarget(node, file)) - } + val nodeBinder = FileTreeViewHolder.ExternalDropHandler { node, file, view -> + bindDropTarget(view, DropTarget(node, file)) + } + + fun bindRootTarget(containerView: View, projectRootDirectory: File) { + bindDropTarget(containerView, DropTarget(null, projectRootDirectory)) + } - fun bindRootTarget(containerView: View, projectRootDirectory: File) { - bindDropTarget(containerView, DropTarget(null, projectRootDirectory)) - } + private fun bindDropTarget(view: View, target: DropTarget) { + val dropCallback = object : DropTargetCallback { + override fun canAcceptDrop(event: DragEvent): Boolean { + return event.hasImportableContent(activity) + } - private fun bindDropTarget(view: View, target: DropTarget) { - val dropCallback = object : DropTargetCallback { - override fun canAcceptDrop(event: DragEvent): Boolean { - return event.hasImportableContent(activity) - } + override fun onDragEntered(view: View) { + DropHighlighter.highlight(view, activity) + } - override fun onDragEntered(view: View) { - DropHighlighter.highlight(view, activity) - } + override fun onDragExited(view: View) { + DropHighlighter.clear(view) + } - override fun onDragExited(view: View) { - DropHighlighter.clear(view) - } + override fun onDrop(event: DragEvent): Boolean { + return importDroppedFiles(target, event) + } + } - override fun onDrop(event: DragEvent): Boolean { - return importDroppedFiles(target, event) - } + view.setOnDragListener(DragEventRouter(dropCallback)) } - view.setOnDragListener(DragEventRouter(dropCallback)) - } + private fun importDroppedFiles(target: DropTarget, event: DragEvent): Boolean { + val context = activity.applicationContext + val clipData = event.clipData ?: return false + val dragPermissions = activity.requestDragAndDropPermissions(event) - private fun importDroppedFiles(target: DropTarget, event: DragEvent): Boolean { - val context = activity.applicationContext - val clipData = event.clipData ?: return false - val dragPermissions = activity.requestDragAndDropPermissions(event) + val lifecycleOwner = activity as? LifecycleOwner + if (lifecycleOwner == null) { + dragPermissions?.release() + return false + } - val lifecycleOwner = activity as? LifecycleOwner - if (lifecycleOwner == null) { - dragPermissions?.release() - return false - } + lifecycleOwner.lifecycleScope.launch { + val result = runCatching { + withContext(Dispatchers.IO) { + FileImporter(context).copyDroppedFiles(clipData, target.file) + } + } - lifecycleOwner.lifecycleScope.launch { - val result = runCatching { - withContext(Dispatchers.IO) { - FileImporter(context).copyDroppedFiles(clipData, target.file) - } - } + dragPermissions?.release() - dragPermissions?.release() + handleImportResult( + target = target, + result = result.getOrNull(), + error = result.exceptionOrNull() + ) + } - handleImportResult( - target = target, - result = result.getOrNull(), - error = result.exceptionOrNull() - ) + return true } - return true - } + private fun handleImportResult( + target: DropTarget, + result: FileImporter.ImportResult?, + error: Throwable? + ) { + if (activity.isFinishing || activity.isDestroyed) return - private fun handleImportResult( - target: DropTarget, - result: FileImporter.ImportResult?, - error: Throwable? - ) { - if (activity.isFinishing || activity.isDestroyed) return + if (error != null) { + onDropFailed(error.toReadableMessage()) + return + } - if (error != null) { - onDropFailed(error.toReadableMessage()) - return + when (result) { + is FileImporter.ImportResult.Success -> handleSuccess(target, result) + is FileImporter.ImportResult.PartialSuccess -> handlePartialSuccess(target, result) + is FileImporter.ImportResult.Failure -> onDropFailed(result.error.toReadableMessage()) + else -> {} + } } - when (result) { - is FileImporter.ImportResult.Success -> handleSuccess(target, result) - is FileImporter.ImportResult.PartialSuccess -> handlePartialSuccess(target, result) - is FileImporter.ImportResult.Failure -> onDropFailed(result.error.toReadableMessage()) - else -> {} + private fun handleSuccess(target: DropTarget, result: FileImporter.ImportResult.Success) { + if (result.count > 0) { + onDropCompleted(target.node, target.file, result.count) + } else { + val noFilesMsg = activity.getString(R.string.msg_file_tree_drop_no_files) + onDropFailed(noFilesMsg) + } } - } - - private fun handleSuccess(target: DropTarget, result: FileImporter.ImportResult.Success) { - if (result.count > 0) { - onDropCompleted(target.node, target.file, result.count) - } else { - val noFilesMsg = activity.getString(R.string.msg_file_tree_drop_no_files) - onDropFailed(noFilesMsg) + + private fun handlePartialSuccess( + target: DropTarget, + result: FileImporter.ImportResult.PartialSuccess + ) { + onDropCompleted(target.node, target.file, result.count) + onDropFailed(result.error.toReadableMessage()) + } + + private fun Throwable.toReadableMessage(): String { + return cause?.message + ?: message + ?: activity.getString(R.string.msg_file_tree_drop_import_failed) } - } - - private fun handlePartialSuccess(target: DropTarget, result: FileImporter.ImportResult.PartialSuccess) { - onDropCompleted(target.node, target.file, result.count) - onDropFailed(result.error.toReadableMessage()) - } - - private fun Throwable.toReadableMessage(): String { - return cause?.message - ?: message - ?: activity.getString(R.string.msg_file_tree_drop_import_failed) - } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt index 66da4b00ed..271edc6afd 100755 --- a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt @@ -61,328 +61,357 @@ import java.io.File import java.util.Arrays class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener, - TreeNodeLongClickListener, TreeNode.TreeNodeDragListener { - - private var binding: LayoutEditorFileTreeBinding? = null - private var fileTreeView: AndroidTreeView? = null - - private val viewModel by viewModels(ownerProducer = { requireActivity() }) - private val fileDragStarter by lazy(LazyThreadSafetyMode.NONE) { - FileDragStarter(requireContext()) - } - private var _dropController: FileTreeDropController? = null - private val dropController: FileTreeDropController - get() { - if (_dropController == null) { - _dropController = FileTreeDropController( - activity = requireActivity(), - onDropCompleted = ::onExternalDropCompleted, - onDropFailed = ::flashError, - ) - } - return _dropController!! - } + TreeNodeLongClickListener, TreeNode.TreeNodeDragListener { - private val externalDropHandler: FileTreeViewHolder.ExternalDropHandler - get() = dropController.nodeBinder + private var binding: LayoutEditorFileTreeBinding? = null + private var fileTreeView: AndroidTreeView? = null - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - if (!EventBus.getDefault().isRegistered(this)) { - EventBus.getDefault().register(this) + private val viewModel by viewModels(ownerProducer = { requireActivity() }) + private val fileDragStarter by lazy(LazyThreadSafetyMode.NONE) { + FileDragStarter(requireContext()) } + private var _dropController: FileTreeDropController? = null + private val dropController: FileTreeDropController + get() { + if (_dropController == null) { + _dropController = FileTreeDropController( + activity = requireActivity(), + onDropCompleted = ::onExternalDropCompleted, + onDropFailed = ::flashError, + ) + } + return _dropController!! + } - binding = LayoutEditorFileTreeBinding.inflate(inflater, container, false) - binding?.root?.doOnApplyWindowInsets { view, insets, _, _ -> - insets.getInsets(statusBars()).apply { view.updatePadding(top = top + SizeUtils.dp2px(8f)) } - } - return binding!!.root - } + private val externalDropHandler: FileTreeViewHolder.ExternalDropHandler + get() = dropController.nodeBinder - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - listProjectFiles() - } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + if (!EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().register(this) + } - override fun onDestroyView() { - super.onDestroyView() - EventBus.getDefault().unregister(this) + binding = LayoutEditorFileTreeBinding.inflate(inflater, container, false) + binding?.root?.doOnApplyWindowInsets { view, insets, _, _ -> + insets.getInsets(statusBars()) + .apply { view.updatePadding(top = top + SizeUtils.dp2px(8f)) } + } + return binding!!.root + } - saveTreeState() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + listProjectFiles() + } - binding = null - fileTreeView = null - _dropController = null - } + override fun onDestroyView() { + super.onDestroyView() + EventBus.getDefault().unregister(this) - fun saveTreeState() { - viewModel.saveState(fileTreeView) - } + saveTreeState() - override fun onClick(node: TreeNode, value: Any) { - val file = value as File - if (!file.exists()) { - return + binding = null + fileTreeView = null + _dropController = null } - if (file.isDirectory) { - if (node.isExpanded) { - collapseNode(node) - } else { - setLoading(node) - listNode(node) { expandNode(node) } - } + + fun saveTreeState() { + viewModel.saveState(fileTreeView) } - val event = FileClickEvent(file) - event.put(Context::class.java, requireContext()) - EventBus.getDefault().post(event) - } - - private fun updateChevron(node: TreeNode) { - if (node.viewHolder is FileTreeViewHolder) { - (node.viewHolder as FileTreeViewHolder).updateChevron(node.isExpanded) + + override fun onClick(node: TreeNode, value: Any) { + val file = value as File + if (!file.exists()) { + return + } + if (file.isDirectory) { + if (node.isExpanded) { + collapseNode(node) + } else { + setLoading(node) + listNode(node) { expandNode(node) } + } + } + val event = FileClickEvent(file) + event.put(Context::class.java, requireContext()) + EventBus.getDefault().post(event) } - } - private fun expandNode(node: TreeNode, animate: Boolean = true) { - if (fileTreeView == null) { - return + private fun updateChevron(node: TreeNode) { + if (node.viewHolder is FileTreeViewHolder) { + (node.viewHolder as FileTreeViewHolder).updateChevron(node.isExpanded) + } } - if (animate) { - TransitionManager.beginDelayedTransition(binding!!.root, ChangeBounds()) + + private fun expandNode(node: TreeNode, animate: Boolean = true) { + if (fileTreeView == null) { + return + } + if (animate) { + TransitionManager.beginDelayedTransition(binding!!.root, ChangeBounds()) + } + fileTreeView!!.expandNode(node) + updateChevron(node) } - fileTreeView!!.expandNode(node) - updateChevron(node) - } - private fun collapseNode(node: TreeNode, animate: Boolean = true, includeSubnodes: Boolean = false) { - if (fileTreeView == null) { - return + private fun collapseNode( + node: TreeNode, + animate: Boolean = true, + includeSubnodes: Boolean = false + ) { + if (fileTreeView == null) { + return + } + if (animate) { + TransitionManager.beginDelayedTransition(binding!!.root, ChangeBounds()) + } + fileTreeView!!.collapseNode(node, includeSubnodes) + updateChevron(node) } - if (animate) { - TransitionManager.beginDelayedTransition(binding!!.root, ChangeBounds()) + + private fun setLoading(node: TreeNode) { + if (node.viewHolder is FileTreeViewHolder) { + (node.viewHolder as FileTreeViewHolder).setLoading(true) + } } - fileTreeView!!.collapseNode(node, includeSubnodes) - updateChevron(node) - } - private fun setLoading(node: TreeNode) { - if (node.viewHolder is FileTreeViewHolder) { - (node.viewHolder as FileTreeViewHolder).setLoading(true) + private fun listNode(node: TreeNode, onListed: () -> Unit) { + val safeContext = context ?: return + val safeDropHandler = externalDropHandler + + node.children.clear() + node.isExpanded = false + executeAsync({ + listFilesForNode( + node.value.listFiles() ?: return@executeAsync null, + node, + safeContext, + safeDropHandler + ) + var temp = node + while (temp.size() == 1) { + temp = temp.childAt(0) + if (!temp.value.isDirectory) { + break + } + listFilesForNode( + temp.value.listFiles() ?: continue, + temp, + safeContext, + safeDropHandler + ) + temp.isExpanded = true + } + null + }) { + onListed() + } } - } - - private fun listNode(node: TreeNode, onListed: () -> Unit) { - val safeContext = context ?: return - val safeDropHandler = externalDropHandler - - node.children.clear() - node.isExpanded = false - executeAsync({ - listFilesForNode(node.value.listFiles() ?: return@executeAsync null, node, safeContext, safeDropHandler) - var temp = node - while (temp.size() == 1) { - temp = temp.childAt(0) - if (!temp.value.isDirectory) { - break + + private fun listFilesForNode( + files: Array, + parent: TreeNode, + context: Context, + dropHandler: FileTreeViewHolder.ExternalDropHandler + ) { + Arrays.sort(files, SortFileName()) + Arrays.sort(files, SortFolder()) + for (file in files) { + parent.addChild(createFileNode(file, context, dropHandler), false) } - listFilesForNode(temp.value.listFiles() ?: continue, temp, safeContext, safeDropHandler) - temp.isExpanded = true - } - null - }) { - onListed() } - } - private fun listFilesForNode(files: Array, parent: TreeNode, context: Context, dropHandler: FileTreeViewHolder.ExternalDropHandler) { - Arrays.sort(files, SortFileName()) - Arrays.sort(files, SortFolder()) - for (file in files) { - parent.addChild(createFileNode(file, context, dropHandler), false) + private fun createFileNode( + file: File, + context: Context, + dropHandler: FileTreeViewHolder.ExternalDropHandler + ): TreeNode { + return TreeNode(file).apply { + viewHolder = FileTreeViewHolder(context, dropHandler) + } } - } - private fun createFileNode(file: File, context: Context, dropHandler: FileTreeViewHolder.ExternalDropHandler): TreeNode { - return TreeNode(file).apply { - viewHolder = FileTreeViewHolder(context, dropHandler) + override fun onLongClick(node: TreeNode, value: Any): Boolean { + val event = FileLongClickEvent((value as File)) + event.put(Context::class.java, requireContext()) + event.put(TreeNode::class.java, node) + EventBus.getDefault().post(event) + return true } - } - - override fun onLongClick(node: TreeNode, value: Any): Boolean { - val event = FileLongClickEvent((value as File)) - event.put(Context::class.java, requireContext()) - event.put(TreeNode::class.java, node) - EventBus.getDefault().post(event) - return true - } - - override fun onStartDrag(node: TreeNode, value: Any) { - val file = value as? File ?: return - val sourceView = node.viewHolder?.view ?: return - - when (val result = fileDragStarter.startDrag(sourceView, file)) { - FileDragResult.Started -> Unit - - is FileDragResult.Failed -> { - val message = when (result.error) { - FileDragError.FileNotFound -> getString(R.string.msg_file_tree_drag_file_not_found) - FileDragError.NotAFile -> getString(R.string.msg_file_tree_drag_not_a_file) - FileDragError.SystemRejected -> getString(R.string.msg_file_tree_drag_failed) - is FileDragError.Exception -> getString(R.string.msg_file_tree_drag_error) + + override fun onStartDrag(node: TreeNode, value: Any) { + val file = value as? File ?: return + val sourceView = node.viewHolder?.view ?: return + + when (val result = fileDragStarter.startDrag(sourceView, file)) { + FileDragResult.Started -> Unit + + is FileDragResult.Failed -> { + val message = when (result.error) { + FileDragError.FileNotFound -> getString(R.string.msg_file_tree_drag_file_not_found) + FileDragError.NotAFile -> getString(R.string.msg_file_tree_drag_not_a_file) + FileDragError.SystemRejected -> getString(R.string.msg_file_tree_drag_failed) + is FileDragError.Exception -> getString(R.string.msg_file_tree_drag_error) + } + flashError(message) + } } - flashError(message) - } } - } - @Suppress("unused", "UNUSED_PARAMETER") - @Subscribe(threadMode = MAIN) - fun onGetListFilesRequested(event: ListProjectFilesRequestEvent?) { - if (!isVisible || context == null) { - return - } - listProjectFiles() - } - - @Suppress("unused") - @Subscribe(threadMode = MAIN) - fun onGetExpandTreeNodeRequest(event: ExpandTreeNodeRequestEvent) { - if (!isVisible || context == null) { - return - } else { - event.node + @Suppress("unused", "UNUSED_PARAMETER") + @Subscribe(threadMode = MAIN) + fun onGetListFilesRequested(event: ListProjectFilesRequestEvent?) { + if (!isVisible || context == null) { + return + } + listProjectFiles() } - expandNode(event.node) - } - - @Suppress("unused") - @Subscribe(threadMode = MAIN) - fun onGetCollapseTreeNodeRequest(event: CollapseTreeNodeRequestEvent) { - if (!isVisible || context == null) { - return - } else { - event.node + + @Suppress("unused") + @Subscribe(threadMode = MAIN) + fun onGetExpandTreeNodeRequest(event: ExpandTreeNodeRequestEvent) { + if (!isVisible || context == null) { + return + } else { + event.node + } + expandNode(event.node) } - collapseNode(event.node, event.includeSubnodes) - setLoading(event.node) - listNode(event.node) { expandNode(event.node) } - } + @Suppress("unused") + @Subscribe(threadMode = MAIN) + fun onGetCollapseTreeNodeRequest(event: CollapseTreeNodeRequestEvent) { + if (!isVisible || context == null) { + return + } else { + event.node + } + collapseNode(event.node, event.includeSubnodes) - fun listProjectFiles() { - if (binding == null) { - // Fragment has been destroyed - return - } - val projectDirPath = IProjectManager.getInstance().projectDirPath - val projectDir = File(projectDirPath) - val rootNode = TreeNode(File("")) - rootNode.viewHolder = FileTreeViewHolder(requireContext(), externalDropHandler) - - val projectRoot = TreeNode.root(projectDir) - projectRoot.viewHolder = FileTreeViewHolder(context, externalDropHandler) - rootNode.addChild(projectRoot, false) - - binding!!.horizontalCroll.visibility = View.GONE - binding!!.horizontalCroll.visibility = View.VISIBLE - executeAsync(FileTreeCallable(context, projectRoot, projectDir, externalDropHandler)) { - if (binding == null) { - // Fragment has been destroyed - return@executeAsync - } - binding!!.horizontalCroll.visibility = View.VISIBLE - binding!!.loading.visibility = View.GONE - val tree = createTreeView(rootNode) - if (tree != null) { - tree.setUseAutoToggle(false) - tree.setDefaultNodeClickListener(this@FileTreeFragment) - tree.setDefaultNodeLongClickListener(this@FileTreeFragment) - binding!!.horizontalCroll.removeAllViews() - val view = tree.view - binding!!.horizontalCroll.addView(view) - dropController.bindRootTarget(binding!!.horizontalCroll, projectDir) - - view.post { tryRestoreState(rootNode) } - } + setLoading(event.node) + listNode(event.node) { expandNode(event.node) } } - } - - private fun createTreeView(node: TreeNode): AndroidTreeView? { - return if (context == null) { - null - } else AndroidTreeView(context, node, drawable.bg_ripple).also { - fileTreeView = it - it.setDefaultNodeDragListener(this) - } - } - - private fun tryRestoreState(rootNode: TreeNode, state: String? = viewModel.savedState) { - if (!TextUtils.isEmpty(state) && fileTreeView != null) { - fileTreeView!!.collapseAll() - val openNodes = - state!!.split(AndroidTreeView.NODES_PATH_SEPARATOR.toRegex()).dropLastWhile { it.isEmpty() } - restoreNodeState(rootNode, HashSet(openNodes)) + + fun listProjectFiles() { + if (binding == null) { + // Fragment has been destroyed + return + } + val projectDirPath = IProjectManager.getInstance().projectDirPath + val projectDir = File(projectDirPath) + val rootNode = TreeNode(File("")) + rootNode.viewHolder = FileTreeViewHolder(requireContext(), externalDropHandler) + + val projectRoot = TreeNode.root(projectDir) + projectRoot.viewHolder = FileTreeViewHolder(context, externalDropHandler) + rootNode.addChild(projectRoot, false) + + binding!!.horizontalCroll.visibility = View.GONE + binding!!.horizontalCroll.visibility = View.VISIBLE + executeAsync(FileTreeCallable(context, projectRoot, projectDir, externalDropHandler)) { + if (binding == null) { + // Fragment has been destroyed + return@executeAsync + } + binding!!.horizontalCroll.visibility = View.VISIBLE + binding!!.loading.visibility = View.GONE + val tree = createTreeView(rootNode) + if (tree != null) { + tree.setUseAutoToggle(false) + tree.setDefaultNodeClickListener(this@FileTreeFragment) + tree.setDefaultNodeLongClickListener(this@FileTreeFragment) + binding!!.horizontalCroll.removeAllViews() + val view = tree.view + binding!!.horizontalCroll.addView(view) + dropController.bindRootTarget(binding!!.horizontalCroll, projectDir) + + view.post { tryRestoreState(rootNode) } + } + } } - if (rootNode.children.isNotEmpty()) { - rootNode.childAt(0)?.let { projectRoot -> expandNode(projectRoot, false) } + private fun createTreeView(node: TreeNode): AndroidTreeView? { + return if (context == null) { + null + } else AndroidTreeView(context, node, drawable.bg_ripple).also { + fileTreeView = it + it.setDefaultNodeDragListener(this) + } } - } - - private fun restoreNodeState(root: TreeNode, openNodes: Set) { - for (node in root.children) { - if (openNodes.contains(node.path)) { - listNode(node) { - expandNode(node, false) - restoreNodeState(node, openNodes) + + private fun tryRestoreState(rootNode: TreeNode, state: String? = viewModel.savedState) { + if (!TextUtils.isEmpty(state) && fileTreeView != null) { + fileTreeView!!.collapseAll() + val openNodes = + state!!.split(AndroidTreeView.NODES_PATH_SEPARATOR.toRegex()) + .dropLastWhile { it.isEmpty() } + restoreNodeState(rootNode, HashSet(openNodes)) + } + + if (rootNode.children.isNotEmpty()) { + rootNode.childAt(0)?.let { projectRoot -> expandNode(projectRoot, false) } } - } } - } - - private fun onExternalDropCompleted(targetNode: TreeNode?, targetFile: File, importedCount: Int) { - refreshNodeAfterDrop(targetNode, targetFile) - flashSuccess( - if (importedCount == 1) { - getString(R.string.msg_file_tree_drop_imported_single) - } else { - getString(R.string.msg_file_tree_drop_imported_multiple, importedCount) - } - ) - } - - private fun refreshNodeAfterDrop(targetNode: TreeNode?, targetFile: File) { - if (targetNode == null) { - listProjectFiles() - return + + private fun restoreNodeState(root: TreeNode, openNodes: Set) { + for (node in root.children) { + if (openNodes.contains(node.path)) { + listNode(node) { + expandNode(node, false) + restoreNodeState(node, openNodes) + } + } + } } - if (targetFile.isDirectory) { - setLoading(targetNode) - listNode(targetNode) { expandNode(targetNode) } - return + private fun onExternalDropCompleted( + targetNode: TreeNode?, + targetFile: File, + importedCount: Int + ) { + refreshNodeAfterDrop(targetNode, targetFile) + flashSuccess( + if (importedCount == 1) { + getString(R.string.msg_file_tree_drop_imported_single) + } else { + getString(R.string.msg_file_tree_drop_imported_multiple, importedCount) + } + ) } - val parentNode = targetNode.parent - if (parentNode?.value?.isDirectory == true) { - setLoading(parentNode) - listNode(parentNode) { expandNode(parentNode) } - } else { - listProjectFiles() + private fun refreshNodeAfterDrop(targetNode: TreeNode?, targetFile: File) { + if (targetNode == null) { + listProjectFiles() + return + } + + if (targetFile.isDirectory) { + setLoading(targetNode) + listNode(targetNode) { expandNode(targetNode) } + return + } + + val parentNode = targetNode.parent + if (parentNode?.value?.isDirectory == true) { + setLoading(parentNode) + listNode(parentNode) { expandNode(parentNode) } + } else { + listProjectFiles() + } } - } - companion object { + companion object { - // Should be same as defined in layout/activity_layouteditor.xml - const val TAG = "editor.fileTree" + // Should be same as defined in layout/activity_layouteditor.xml + const val TAG = "editor.fileTree" - @JvmStatic - fun newInstance(): FileTreeFragment { - return FileTreeFragment() + @JvmStatic + fun newInstance(): FileTreeFragment { + return FileTreeFragment() + } } - } } diff --git a/app/src/main/java/com/itsaky/androidide/tasks/callables/FileTreeCallable.java b/app/src/main/java/com/itsaky/androidide/tasks/callables/FileTreeCallable.java index 986e7f09e1..afe8454fd9 100755 --- a/app/src/main/java/com/itsaky/androidide/tasks/callables/FileTreeCallable.java +++ b/app/src/main/java/com/itsaky/androidide/tasks/callables/FileTreeCallable.java @@ -21,8 +21,10 @@ package com.itsaky.androidide.tasks.callables; import android.content.Context; + import com.itsaky.androidide.adapters.viewholders.FileTreeViewHolder; import com.unnamed.b.atv.model.TreeNode; + import java.io.File; import java.io.FileFilter; import java.util.Arrays; @@ -30,66 +32,66 @@ import java.util.concurrent.Callable; public class FileTreeCallable implements Callable { - private final Context ctx; - private final TreeNode parent; - private final File file; - private final FileTreeViewHolder.ExternalDropHandler externalDropHandler; + private final Context ctx; + private final TreeNode parent; + private final File file; + private final FileTreeViewHolder.ExternalDropHandler externalDropHandler; - public FileTreeCallable(Context ctx, TreeNode parent, File file) { - this(ctx, parent, file, null); - } + public FileTreeCallable(Context ctx, TreeNode parent, File file) { + this(ctx, parent, file, null); + } - public FileTreeCallable( - Context ctx, TreeNode parent, File file, FileTreeViewHolder.ExternalDropHandler externalDropHandler) { - this.ctx = ctx; - this.parent = parent; - this.file = file; - this.externalDropHandler = externalDropHandler; - } + public FileTreeCallable( + Context ctx, TreeNode parent, File file, FileTreeViewHolder.ExternalDropHandler externalDropHandler) { + this.ctx = ctx; + this.parent = parent; + this.file = file; + this.externalDropHandler = externalDropHandler; + } - @Override - public Boolean call() throws Exception { - populateChildren(file.listFiles(/*new HiddenFilesFilter()*/ ), parent); - return true; - } + @Override + public Boolean call() throws Exception { + populateChildren(file.listFiles(/*new HiddenFilesFilter()*/), parent); + return true; + } - private void populateChildren(File[] files, TreeNode parent) { - if (files == null) return; + private void populateChildren(File[] files, TreeNode parent) { + if (files == null) return; - Arrays.sort(files, new SortFileName()); - Arrays.sort(files, new SortFolder()); - for (File file : files) { - parent.addChild(createNode(file), false); + Arrays.sort(files, new SortFileName()); + Arrays.sort(files, new SortFolder()); + for (File file : files) { + parent.addChild(createNode(file), false); + } } - } - private TreeNode createNode(File file) { - TreeNode node = new TreeNode(file); - node.setViewHolder(new FileTreeViewHolder(ctx, externalDropHandler)); - return node; - } + private TreeNode createNode(File file) { + TreeNode node = new TreeNode(file); + node.setViewHolder(new FileTreeViewHolder(ctx, externalDropHandler)); + return node; + } - public static class HiddenFilesFilter implements FileFilter { + public static class HiddenFilesFilter implements FileFilter { - @Override - public boolean accept(File p1) { - return !p1.getName().startsWith("."); + @Override + public boolean accept(File p1) { + return !p1.getName().startsWith("."); + } } - } - public static class SortFileName implements Comparator { - @Override - public int compare(File f1, File f2) { - return f1.getName().compareTo(f2.getName()); + public static class SortFileName implements Comparator { + @Override + public int compare(File f1, File f2) { + return f1.getName().compareTo(f2.getName()); + } } - } - public static class SortFolder implements Comparator { - @Override - public int compare(File f1, File f2) { - if (f1.isDirectory() == f2.isDirectory()) return 0; - else if (f1.isDirectory() && !f2.isDirectory()) return -1; - else return 1; + public static class SortFolder implements Comparator { + @Override + public int compare(File f1, File f2) { + if (f1.isDirectory() == f2.isDirectory()) return 0; + else if (f1.isDirectory() && !f2.isDirectory()) return -1; + else return 1; + } } - } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/FileImporter.kt b/app/src/main/java/com/itsaky/androidide/utils/FileImporter.kt index 95186e73fc..ccb5a7ab1c 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/FileImporter.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/FileImporter.kt @@ -50,9 +50,14 @@ class FileImporter(private val context: Context) { context = context, uri = uri, destinationFile = destinationFile, - onOpenFailed = { IllegalStateException( - context.getString(R.string.msg_file_tree_drop_read_failed, sanitizedName) - )} + onOpenFailed = { + IllegalStateException( + context.getString( + R.string.msg_file_tree_drop_read_failed, + sanitizedName + ) + ) + } ) } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/UriFileImporter.kt b/app/src/main/java/com/itsaky/androidide/utils/UriFileImporter.kt index 283273fa8f..49dd83da4f 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/UriFileImporter.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/UriFileImporter.kt @@ -8,69 +8,69 @@ import android.util.Log import java.io.File object UriFileImporter { - const val TAG = "UriFileImporter" + const val TAG = "UriFileImporter" - @JvmStatic - fun copyUriToFile( - context: Context, - uri: Uri, - destinationFile: File, - onOpenFailed: (() -> Throwable)? = null, - ) { - copyUriToFile( - contentResolver = context.contentResolver, - uri = uri, - destinationFile = destinationFile, - onOpenFailed = onOpenFailed, - ) - } + @JvmStatic + fun copyUriToFile( + context: Context, + uri: Uri, + destinationFile: File, + onOpenFailed: (() -> Throwable)? = null, + ) { + copyUriToFile( + contentResolver = context.contentResolver, + uri = uri, + destinationFile = destinationFile, + onOpenFailed = onOpenFailed, + ) + } - @JvmStatic - fun copyUriToFile( - contentResolver: ContentResolver, - uri: Uri, - destinationFile: File, - onOpenFailed: (() -> Throwable)? = null, - ) { - val inputStream = contentResolver.openInputStream(uri) - ?: throw (onOpenFailed?.invoke() ?: IllegalStateException("Unable to open URI: $uri")) + @JvmStatic + fun copyUriToFile( + contentResolver: ContentResolver, + uri: Uri, + destinationFile: File, + onOpenFailed: (() -> Throwable)? = null, + ) { + val inputStream = contentResolver.openInputStream(uri) + ?: throw (onOpenFailed?.invoke() ?: IllegalStateException("Unable to open URI: $uri")) - try { - inputStream.use { input -> - destinationFile.outputStream().use { output -> - input.copyTo(output) + try { + inputStream.use { input -> + destinationFile.outputStream().use { output -> + input.copyTo(output) + } + } + } catch (e: Exception) { + if (destinationFile.exists()) { + destinationFile.delete() + } + throw e } - } - } catch (e: Exception) { - if (destinationFile.exists()) { - destinationFile.delete() - } - throw e } - } - @JvmStatic - fun getDisplayName(contentResolver: ContentResolver, uri: Uri): String? { - return try { - when (uri.scheme) { - "content" -> queryDisplayName(contentResolver, uri) ?: uri.lastPathSegment - "file" -> uri.lastPathSegment - else -> uri.lastPathSegment - } - } catch (e: Exception) { - Log.e(TAG, "Error getting filename from URI", e) - uri.lastPathSegment + @JvmStatic + fun getDisplayName(contentResolver: ContentResolver, uri: Uri): String? { + return try { + when (uri.scheme) { + "content" -> queryDisplayName(contentResolver, uri) ?: uri.lastPathSegment + "file" -> uri.lastPathSegment + else -> uri.lastPathSegment + } + } catch (e: Exception) { + Log.e(TAG, "Error getting filename from URI", e) + uri.lastPathSegment + } } - } - private fun queryDisplayName(contentResolver: ContentResolver, uri: Uri): String? { - return contentResolver.query(uri, null, null, null, null)?.use { cursor -> - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (nameIndex != -1 && cursor.moveToFirst()) { - cursor.getString(nameIndex) - } else { - null - } + private fun queryDisplayName(contentResolver: ContentResolver, uri: Uri): String? { + return contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1 && cursor.moveToFirst()) { + cursor.getString(nameIndex) + } else { + null + } + } } - } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt index 3d9289464b..17112309d4 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -70,7 +70,11 @@ class PluginManagerViewModel( is PluginManagerUiEvent.EnablePlugin -> enablePlugin(event.pluginId) is PluginManagerUiEvent.DisablePlugin -> disablePlugin(event.pluginId) is PluginManagerUiEvent.UninstallPlugin -> showUninstallConfirmation(event.pluginId) - is PluginManagerUiEvent.InstallPlugin -> installPlugin(event.uri, event.deleteSourceAfterInstall) + is PluginManagerUiEvent.InstallPlugin -> installPlugin( + event.uri, + event.deleteSourceAfterInstall + ) + is PluginManagerUiEvent.OpenFilePicker -> openFilePicker() is PluginManagerUiEvent.ShowPluginDetails -> showPluginDetails(event.plugin) } @@ -239,7 +243,11 @@ class PluginManagerViewModel( try { tempFile = withContext(Dispatchers.IO) { val fileName = UriFileImporter.getDisplayName(contentResolver, uri) - val extension = if (fileName?.endsWith(".cgp", ignoreCase = true) == true) ".cgp" else ".apk" + val extension = if (fileName?.endsWith( + ".cgp", + ignoreCase = true + ) == true + ) ".cgp" else ".apk" val tempFileName = "temp_plugin_${System.currentTimeMillis()}$extension" val tempDir = File(filesDir, "temp").apply { mkdirs() } val tempFile = File(tempDir, tempFileName) diff --git a/treeview/src/main/java/com/unnamed/b/atv/model/TreeNode.java b/treeview/src/main/java/com/unnamed/b/atv/model/TreeNode.java index d273d0071c..a1b3d2521b 100755 --- a/treeview/src/main/java/com/unnamed/b/atv/model/TreeNode.java +++ b/treeview/src/main/java/com/unnamed/b/atv/model/TreeNode.java @@ -15,335 +15,337 @@ import java.util.Comparator; import java.util.List; -/** Created by Bogdan Melnychuk on 2/10/15. */ +/** + * Created by Bogdan Melnychuk on 2/10/15. + */ public class TreeNode { - public static final String NODES_ID_SEPARATOR = ":"; - private final List children; - private int mId; - private int mLastId; - private TreeNode mParent; - private boolean mSelected; - private boolean mSelectable = true; - private BaseNodeViewHolder mViewHolder; - private TreeNodeClickListener mClickListener; - private TreeNodeLongClickListener mLongClickListener; - private File mValue; - private boolean mExpanded; - private boolean mIsDirectory; - private TreeNodeDragListener mDragListener; - - public TreeNode(File value) { - children = Collections.synchronizedList(new ArrayList()); - mValue = value; - if (value != null) { - mIsDirectory = value.isDirectory(); - } - } - - public static TreeNode root() { - return root(null); - } - - public static TreeNode root(File value) { - TreeNode root = new TreeNode(value); - root.setSelectable(false); - return root; - } - - public TreeNode addChildren(TreeNode... nodes) { - for (TreeNode n : nodes) { - addChild(n); - } - return this; - } - - public TreeNode addChild(TreeNode childNode) { - return addChild(childNode, true); - } - - public TreeNode addChild(TreeNode childNode, boolean sort) { - childNode.mParent = this; - childNode.mId = generateId(); - children.add(childNode); - - if (sort) { - Collections.sort(children, new SortFileName()); - Collections.sort(children, new SortFolder()); - } - return this; - } - - private int generateId() { - return ++mLastId; - } - - public TreeNode addChildren(Collection nodes) { - for (TreeNode n : nodes) { - addChild(n); - } - return this; - } - - public TreeNode childAt(int index) { - return children == null ? null : children.get(index); - } - - public void deleteAllChildren() { - children.clear(); - } - - public int deleteChild(TreeNode child) { - for (int i = 0; i < children.size(); i++) { - if (child.mId == children.get(i).mId) { - children.remove(i); - return i; - } - } - return -1; - } - - public List getChildren() { - return children == null ? Collections.synchronizedList(new ArrayList()) : children; - } - - public TreeNode getParent() { - return mParent; - } - - public boolean isLeaf() { - return size() == 0; - } + public static final String NODES_ID_SEPARATOR = ":"; + private final List children; + private int mId; + private int mLastId; + private TreeNode mParent; + private boolean mSelected; + private boolean mSelectable = true; + private BaseNodeViewHolder mViewHolder; + private TreeNodeClickListener mClickListener; + private TreeNodeLongClickListener mLongClickListener; + private File mValue; + private boolean mExpanded; + private boolean mIsDirectory; + private TreeNodeDragListener mDragListener; + + public TreeNode(File value) { + children = Collections.synchronizedList(new ArrayList()); + mValue = value; + if (value != null) { + mIsDirectory = value.isDirectory(); + } + } - public boolean isDirectory() { - return mIsDirectory; + public static TreeNode root() { + return root(null); } - public int size() { - return children == null ? 0 : children.size(); - } - - public File getValue() { - return mValue; - } - - public TreeNode setValue(File file) { - this.mValue = file; - return this; - } - - public boolean isExpanded() { - return mExpanded; - } - - public TreeNode setExpanded(boolean expanded) { - mExpanded = expanded; - return this; - } - - public boolean isSelected() { - return mSelectable && mSelected; - } - - public void setSelected(boolean selected) { - mSelected = selected; - } - - public boolean isSelectable() { - return mSelectable; - } - - public void setSelectable(boolean selectable) { - mSelectable = selectable; - } - - public String getPath() { - final StringBuilder path = new StringBuilder(); - TreeNode node = this; - while (node.mParent != null) { - path.append(node.getId()); - node = node.mParent; - if (node.mParent != null) { - path.append(NODES_ID_SEPARATOR); - } - } - return path.toString(); - } - - public int getId() { - return mId; - } + public static TreeNode root(File value) { + TreeNode root = new TreeNode(value); + root.setSelectable(false); + return root; + } - public int getLevel() { - int level = 0; - TreeNode root = this; - while (root.mParent != null) { - root = root.mParent; - level++; + public TreeNode addChildren(TreeNode... nodes) { + for (TreeNode n : nodes) { + addChild(n); + } + return this; } - return level; - } - public boolean isLastChild() { - if (!isRoot()) { - int parentSize = mParent.children.size(); - if (parentSize > 0) { - final List parentChildren = mParent.children; - return parentChildren.get(parentSize - 1).mId == mId; - } + public TreeNode addChild(TreeNode childNode) { + return addChild(childNode, true); } - return false; - } - public boolean isRoot() { - return mParent == null; - } + public TreeNode addChild(TreeNode childNode, boolean sort) { + childNode.mParent = this; + childNode.mId = generateId(); + children.add(childNode); - public TreeNodeClickListener getClickListener() { - return this.mClickListener; - } + if (sort) { + Collections.sort(children, new SortFileName()); + Collections.sort(children, new SortFolder()); + } + return this; + } - public TreeNode setClickListener(TreeNodeClickListener listener) { - mClickListener = listener; - return this; - } + private int generateId() { + return ++mLastId; + } - public TreeNodeLongClickListener getLongClickListener() { - return mLongClickListener; - } + public TreeNode addChildren(Collection nodes) { + for (TreeNode n : nodes) { + addChild(n); + } + return this; + } - public TreeNode setLongClickListener(TreeNodeLongClickListener listener) { - mLongClickListener = listener; - return this; - } + public TreeNode childAt(int index) { + return children == null ? null : children.get(index); + } - public TreeNodeDragListener getDragListener() { - return mDragListener; - } + public void deleteAllChildren() { + children.clear(); + } - public TreeNode setDragListener(TreeNodeDragListener listener) { - mDragListener = listener; - return this; - } + public int deleteChild(TreeNode child) { + for (int i = 0; i < children.size(); i++) { + if (child.mId == children.get(i).mId) { + children.remove(i); + return i; + } + } + return -1; + } - public BaseNodeViewHolder getViewHolder() { - return mViewHolder; - } + public List getChildren() { + return children == null ? Collections.synchronizedList(new ArrayList()) : children; + } - public TreeNode setViewHolder(BaseNodeViewHolder viewHolder) { - mViewHolder = viewHolder; - if (viewHolder != null) { - viewHolder.mNode = this; + public TreeNode getParent() { + return mParent; } - return this; - } - public boolean isFirstChild() { - if (!isRoot()) { - List parentChildren = mParent.children; - return parentChildren.get(0).mId == mId; + public boolean isLeaf() { + return size() == 0; } - return false; - } - public TreeNode getRoot() { - TreeNode root = this; - while (root.mParent != null) { - root = root.mParent; + public boolean isDirectory() { + return mIsDirectory; } - return root; - } - public abstract static class BaseNodeViewHolder { - protected AndroidTreeView tView; - protected TreeNode mNode; - protected int containerStyle; - protected Context context; - private View mView; + public int size() { + return children == null ? 0 : children.size(); + } + + public File getValue() { + return mValue; + } - public BaseNodeViewHolder(Context context) { - this.context = context; + public TreeNode setValue(File file) { + this.mValue = file; + return this; } - public void setTreeViev(AndroidTreeView treeViev) { - this.tView = treeViev; + public boolean isExpanded() { + return mExpanded; } - public AndroidTreeView getTreeView() { - return tView; + public TreeNode setExpanded(boolean expanded) { + mExpanded = expanded; + return this; } - public ViewGroup getNodeItemsView() { - return (ViewGroup) getView().findViewById(R.id.node_items); + public boolean isSelected() { + return mSelectable && mSelected; } - public View getView() { - if (mView != null) { - return mView; - } - final View nodeView = getNodeView(); - final TreeNodeWrapperView nodeWrapperView = - new TreeNodeWrapperView(nodeView.getContext(), getContainerStyle()); - nodeWrapperView.insertNodeView(nodeView); - mView = nodeWrapperView; + public void setSelected(boolean selected) { + mSelected = selected; + } - return mView; + public boolean isSelectable() { + return mSelectable; } - public View getNodeView() { - return createNodeView(mNode, (E) mNode.getValue()); + public void setSelectable(boolean selectable) { + mSelectable = selectable; } - public abstract View createNodeView(TreeNode node, E value); + public String getPath() { + final StringBuilder path = new StringBuilder(); + TreeNode node = this; + while (node.mParent != null) { + path.append(node.getId()); + node = node.mParent; + if (node.mParent != null) { + path.append(NODES_ID_SEPARATOR); + } + } + return path.toString(); + } - public int getContainerStyle() { - return containerStyle; + public int getId() { + return mId; } - public void setContainerStyle(int style) { - containerStyle = style; + public int getLevel() { + int level = 0; + TreeNode root = this; + while (root.mParent != null) { + root = root.mParent; + level++; + } + return level; } - public boolean isInitialized() { - return mView != null; + public boolean isLastChild() { + if (!isRoot()) { + int parentSize = mParent.children.size(); + if (parentSize > 0) { + final List parentChildren = mParent.children; + return parentChildren.get(parentSize - 1).mId == mId; + } + } + return false; } - public void toggle(boolean active) { - // empty + public boolean isRoot() { + return mParent == null; } - public void toggleSelectionMode(boolean editModeEnabled) { - // empty + public TreeNodeClickListener getClickListener() { + return this.mClickListener; } - } - public class SortFileName implements Comparator { - @Override - public int compare(TreeNode f1, TreeNode f2) { - return f1.getValue().getName().compareTo(f2.getValue().getName()); + public TreeNode setClickListener(TreeNodeClickListener listener) { + mClickListener = listener; + return this; } - } - public class SortFolder implements Comparator { - @Override - public int compare(TreeNode p1, TreeNode p2) { - File f1 = p1.getValue(); - File f2 = p2.getValue(); - if (f1.isDirectory() == f2.isDirectory()) return 0; - else if (f1.isDirectory() && !f2.isDirectory()) return -1; - else return 1; + public TreeNodeLongClickListener getLongClickListener() { + return mLongClickListener; } - } - public interface TreeNodeClickListener { - void onClick(TreeNode node, Object value); - } + public TreeNode setLongClickListener(TreeNodeLongClickListener listener) { + mLongClickListener = listener; + return this; + } - public interface TreeNodeLongClickListener { - boolean onLongClick(TreeNode node, Object value); - } + public TreeNodeDragListener getDragListener() { + return mDragListener; + } - public interface TreeNodeDragListener { - void onStartDrag(TreeNode node, Object value); - } + public TreeNode setDragListener(TreeNodeDragListener listener) { + mDragListener = listener; + return this; + } + + public BaseNodeViewHolder getViewHolder() { + return mViewHolder; + } + + public TreeNode setViewHolder(BaseNodeViewHolder viewHolder) { + mViewHolder = viewHolder; + if (viewHolder != null) { + viewHolder.mNode = this; + } + return this; + } + + public boolean isFirstChild() { + if (!isRoot()) { + List parentChildren = mParent.children; + return parentChildren.get(0).mId == mId; + } + return false; + } + + public TreeNode getRoot() { + TreeNode root = this; + while (root.mParent != null) { + root = root.mParent; + } + return root; + } + + public abstract static class BaseNodeViewHolder { + protected AndroidTreeView tView; + protected TreeNode mNode; + protected int containerStyle; + protected Context context; + private View mView; + + public BaseNodeViewHolder(Context context) { + this.context = context; + } + + public void setTreeViev(AndroidTreeView treeViev) { + this.tView = treeViev; + } + + public AndroidTreeView getTreeView() { + return tView; + } + + public ViewGroup getNodeItemsView() { + return (ViewGroup) getView().findViewById(R.id.node_items); + } + + public View getView() { + if (mView != null) { + return mView; + } + final View nodeView = getNodeView(); + final TreeNodeWrapperView nodeWrapperView = + new TreeNodeWrapperView(nodeView.getContext(), getContainerStyle()); + nodeWrapperView.insertNodeView(nodeView); + mView = nodeWrapperView; + + return mView; + } + + public View getNodeView() { + return createNodeView(mNode, (E) mNode.getValue()); + } + + public abstract View createNodeView(TreeNode node, E value); + + public int getContainerStyle() { + return containerStyle; + } + + public void setContainerStyle(int style) { + containerStyle = style; + } + + public boolean isInitialized() { + return mView != null; + } + + public void toggle(boolean active) { + // empty + } + + public void toggleSelectionMode(boolean editModeEnabled) { + // empty + } + } + + public class SortFileName implements Comparator { + @Override + public int compare(TreeNode f1, TreeNode f2) { + return f1.getValue().getName().compareTo(f2.getValue().getName()); + } + } + + public class SortFolder implements Comparator { + @Override + public int compare(TreeNode p1, TreeNode p2) { + File f1 = p1.getValue(); + File f2 = p2.getValue(); + if (f1.isDirectory() == f2.isDirectory()) return 0; + else if (f1.isDirectory() && !f2.isDirectory()) return -1; + else return 1; + } + } + + public interface TreeNodeClickListener { + void onClick(TreeNode node, Object value); + } + + public interface TreeNodeLongClickListener { + boolean onLongClick(TreeNode node, Object value); + } + + public interface TreeNodeDragListener { + void onStartDrag(TreeNode node, Object value); + } } diff --git a/treeview/src/main/java/com/unnamed/b/atv/view/AndroidTreeView.java b/treeview/src/main/java/com/unnamed/b/atv/view/AndroidTreeView.java index 2a217ab3c6..5c635e52e4 100755 --- a/treeview/src/main/java/com/unnamed/b/atv/view/AndroidTreeView.java +++ b/treeview/src/main/java/com/unnamed/b/atv/view/AndroidTreeView.java @@ -23,471 +23,475 @@ import java.util.List; import java.util.Set; -/** Created by Bogdan Melnychuk on 2/10/15. */ +/** + * Created by Bogdan Melnychuk on 2/10/15. + */ @SuppressWarnings("rawtypes") public class AndroidTreeView { - public static final String NODES_PATH_SEPARATOR = ";"; - private final Context mContext; - private final int nodeViewBackground; - protected TreeNode mRoot; - private boolean applyForRoot; - private int containerStyle = 0; - private TreeNode.BaseNodeViewHolder defaultViewHolder; - private TreeNode.TreeNodeClickListener nodeClickListener; - private TreeNode.TreeNodeLongClickListener nodeLongClickListener; - private TreeNode.TreeNodeDragListener nodeDragListener; - private boolean mSelectionModeEnabled; - private boolean use2dScroll = false; - private boolean enableAutoToggle = true; - - public AndroidTreeView(Context context, TreeNode root, @DrawableRes int nodeBackground) { - this.mRoot = root; - this.mContext = context; - this.nodeViewBackground = nodeBackground; - this.defaultViewHolder = new SimpleViewHolder(context); - } - - private static void expand(final View v) { - v.measure(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); - final int targetHeight = v.getMeasuredHeight(); - - v.getLayoutParams().height = 0; - v.setVisibility(View.VISIBLE); - Animation a = - new Animation() { - @Override - public boolean willChangeBounds() { - return true; - } - - @Override - protected void applyTransformation(float interpolatedTime, Transformation t) { - v.getLayoutParams().height = - interpolatedTime == 1 - ? LinearLayout.LayoutParams.WRAP_CONTENT - : (int) (targetHeight * interpolatedTime); - v.requestLayout(); - } - }; - - // 1dp/ms - a.setDuration( - (int) ((targetHeight / v.getContext().getResources().getDisplayMetrics().density) / 2)); - v.startAnimation(a); - } - - private static void collapse(final View v) { - final int initialHeight = v.getMeasuredHeight(); - - Animation a = - new Animation() { - @Override - public boolean willChangeBounds() { - return true; - } - - @Override - protected void applyTransformation(float interpolatedTime, Transformation t) { - if (interpolatedTime == 1) { - v.setVisibility(View.GONE); - } else { - v.getLayoutParams().height = initialHeight - (int) (initialHeight * interpolatedTime); - v.requestLayout(); + public static final String NODES_PATH_SEPARATOR = ";"; + private final Context mContext; + private final int nodeViewBackground; + protected TreeNode mRoot; + private boolean applyForRoot; + private int containerStyle = 0; + private TreeNode.BaseNodeViewHolder defaultViewHolder; + private TreeNode.TreeNodeClickListener nodeClickListener; + private TreeNode.TreeNodeLongClickListener nodeLongClickListener; + private TreeNode.TreeNodeDragListener nodeDragListener; + private boolean mSelectionModeEnabled; + private boolean use2dScroll = false; + private boolean enableAutoToggle = true; + + public AndroidTreeView(Context context, TreeNode root, @DrawableRes int nodeBackground) { + this.mRoot = root; + this.mContext = context; + this.nodeViewBackground = nodeBackground; + this.defaultViewHolder = new SimpleViewHolder(context); + } + + private static void expand(final View v) { + v.measure(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); + final int targetHeight = v.getMeasuredHeight(); + + v.getLayoutParams().height = 0; + v.setVisibility(View.VISIBLE); + Animation a = + new Animation() { + @Override + public boolean willChangeBounds() { + return true; + } + + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + v.getLayoutParams().height = + interpolatedTime == 1 + ? LinearLayout.LayoutParams.WRAP_CONTENT + : (int) (targetHeight * interpolatedTime); + v.requestLayout(); + } + }; + + // 1dp/ms + a.setDuration( + (int) ((targetHeight / v.getContext().getResources().getDisplayMetrics().density) / 2)); + v.startAnimation(a); + } + + private static void collapse(final View v) { + final int initialHeight = v.getMeasuredHeight(); + + Animation a = + new Animation() { + @Override + public boolean willChangeBounds() { + return true; + } + + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + if (interpolatedTime == 1) { + v.setVisibility(View.GONE); + } else { + v.getLayoutParams().height = initialHeight - (int) (initialHeight * interpolatedTime); + v.requestLayout(); + } + } + }; + + // 1dp/ms + a.setDuration( + (int) ((initialHeight / v.getContext().getResources().getDisplayMetrics().density) / 2)); + v.startAnimation(a); + } + + public void setRoot(TreeNode mRoot) { + this.mRoot = mRoot; + } + + public void setDefaultContainerStyle(int style) { + setDefaultContainerStyle(style, false); + } + + public void setDefaultContainerStyle(int style, boolean applyForRoot) { + containerStyle = style; + this.applyForRoot = applyForRoot; + } + + public void setUse2dScroll(boolean use2dScroll) { + this.use2dScroll = use2dScroll; + } + + public boolean is2dScrollEnabled() { + return use2dScroll; + } + + public void setUseAutoToggle(boolean enableAutoToggle) { + this.enableAutoToggle = enableAutoToggle; + } + + public boolean isAutoToggleEnabled() { + return enableAutoToggle; + } + + public void setDefaultViewHolder(TreeNode.BaseNodeViewHolder viewHolder) { + defaultViewHolder = viewHolder; + } + + public void setDefaultNodeClickListener(TreeNode.TreeNodeClickListener listener) { + nodeClickListener = listener; + } + + public void setDefaultNodeLongClickListener(TreeNode.TreeNodeLongClickListener listener) { + nodeLongClickListener = listener; + } + + public void setDefaultNodeDragListener(TreeNode.TreeNodeDragListener listener) { + this.nodeDragListener = listener; + } + + public void expandAll() { + expandNode(mRoot, true); + } + + public void collapseAll() { + for (var i = 0; i < mRoot.getChildren().size(); i++) { + final var n = mRoot.childAt(i); + collapseNode(n, true); + } + } + + public View getView(int style) { + final ViewGroup view; + if (style > 0) { + ContextThemeWrapper newContext = new ContextThemeWrapper(mContext, style); + view = use2dScroll ? new TwoDScrollView(newContext) : new ScrollView(newContext); + } else { + view = use2dScroll ? new TwoDScrollView(mContext) : new ScrollView(mContext); + } + + Context containerContext = mContext; + if (containerStyle != 0 && applyForRoot) { + containerContext = new ContextThemeWrapper(mContext, containerStyle); + } + final LinearLayout viewTreeItems = new LinearLayout(containerContext, null, containerStyle); + + viewTreeItems.setId(R.id.tree_items); + viewTreeItems.setOrientation(LinearLayout.VERTICAL); + view.addView(viewTreeItems); + + view.setNestedScrollingEnabled(false); + + mRoot.setViewHolder( + new TreeNode.BaseNodeViewHolder(mContext) { + @Override + public ViewGroup getNodeItemsView() { + return viewTreeItems; + } + + @Override + public View createNodeView(TreeNode node, Object value) { + return null; + } + }); + + expandNode(mRoot, false); + return view; + } + + public View getView() { + return getView(-1); + } + + public void expandLevel(int level) { + List children = mRoot.getChildren(); + for (int i = 0; i < children.size(); i++) { + TreeNode n = children.get(i); + expandLevel(n, level); + } + } + + public void expandNode(TreeNode node) { + expandNode(node, false); + } + + public void collapseNode(TreeNode node) { + collapseNode(node, false); + } + + public String getSaveState() { + final StringBuilder builder = new StringBuilder(); + getSaveState(mRoot, builder); + if (builder.length() > 0) { + builder.setLength(builder.length() - 1); + } + return builder.toString(); + } + + public void restoreState(String saveState) { + if (!TextUtils.isEmpty(saveState)) { + collapseAll(); + final String[] openNodesArray = saveState.split(NODES_PATH_SEPARATOR); + final Set openNodes = new HashSet<>(Arrays.asList(openNodesArray)); + restoreNodeState(mRoot, openNodes); + } + } + + public void toggleNode(TreeNode node) { + if (node.isExpanded()) { + collapseNode(node, false); + } else { + expandNode(node, false); + } + } + + public void collapseNode(TreeNode node, final boolean includeSubnodes) { + node.setExpanded(false); + TreeNode.BaseNodeViewHolder nodeViewHolder = getViewHolderForNode(node); + nodeViewHolder.getNodeItemsView().setVisibility(View.GONE); + nodeViewHolder.toggle(false); + if (includeSubnodes) { + List children = node.getChildren(); + for (int i = 0; i < children.size(); i++) { + TreeNode n = children.get(i); + collapseNode(n, true); } - } - }; + } + } + + public void expandNode(final TreeNode node, boolean includeSubnodes) { + node.setExpanded(true); + final TreeNode.BaseNodeViewHolder parentViewHolder = getViewHolderForNode(node); + parentViewHolder.getNodeItemsView().removeAllViews(); - // 1dp/ms - a.setDuration( - (int) ((initialHeight / v.getContext().getResources().getDisplayMetrics().density) / 2)); - v.startAnimation(a); - } - - public void setRoot(TreeNode mRoot) { - this.mRoot = mRoot; - } - - public void setDefaultContainerStyle(int style) { - setDefaultContainerStyle(style, false); - } - - public void setDefaultContainerStyle(int style, boolean applyForRoot) { - containerStyle = style; - this.applyForRoot = applyForRoot; - } - - public void setUse2dScroll(boolean use2dScroll) { - this.use2dScroll = use2dScroll; - } - - public boolean is2dScrollEnabled() { - return use2dScroll; - } - - public void setUseAutoToggle(boolean enableAutoToggle) { - this.enableAutoToggle = enableAutoToggle; - } - - public boolean isAutoToggleEnabled() { - return enableAutoToggle; - } - - public void setDefaultViewHolder(TreeNode.BaseNodeViewHolder viewHolder) { - defaultViewHolder = viewHolder; - } - - public void setDefaultNodeClickListener(TreeNode.TreeNodeClickListener listener) { - nodeClickListener = listener; - } - - public void setDefaultNodeLongClickListener(TreeNode.TreeNodeLongClickListener listener) { - nodeLongClickListener = listener; - } - - public void setDefaultNodeDragListener(TreeNode.TreeNodeDragListener listener) { - this.nodeDragListener = listener; - } - - public void expandAll() { - expandNode(mRoot, true); - } - - public void collapseAll() { - for (var i = 0; i < mRoot.getChildren().size(); i++) { - final var n = mRoot.childAt(i); - collapseNode(n, true); - } - } - - public View getView(int style) { - final ViewGroup view; - if (style > 0) { - ContextThemeWrapper newContext = new ContextThemeWrapper(mContext, style); - view = use2dScroll ? new TwoDScrollView(newContext) : new ScrollView(newContext); - } else { - view = use2dScroll ? new TwoDScrollView(mContext) : new ScrollView(mContext); - } - - Context containerContext = mContext; - if (containerStyle != 0 && applyForRoot) { - containerContext = new ContextThemeWrapper(mContext, containerStyle); - } - final LinearLayout viewTreeItems = new LinearLayout(containerContext, null, containerStyle); - - viewTreeItems.setId(R.id.tree_items); - viewTreeItems.setOrientation(LinearLayout.VERTICAL); - view.addView(viewTreeItems); - - view.setNestedScrollingEnabled(false); - - mRoot.setViewHolder( - new TreeNode.BaseNodeViewHolder(mContext) { - @Override - public ViewGroup getNodeItemsView() { - return viewTreeItems; - } - - @Override - public View createNodeView(TreeNode node, Object value) { - return null; - } - }); - - expandNode(mRoot, false); - return view; - } - - public View getView() { - return getView(-1); - } - - public void expandLevel(int level) { - List children = mRoot.getChildren(); - for (int i = 0; i < children.size(); i++) { - TreeNode n = children.get(i); - expandLevel(n, level); - } - } - - public void expandNode(TreeNode node) { - expandNode(node, false); - } - - public void collapseNode(TreeNode node) { - collapseNode(node, false); - } - - public String getSaveState() { - final StringBuilder builder = new StringBuilder(); - getSaveState(mRoot, builder); - if (builder.length() > 0) { - builder.setLength(builder.length() - 1); - } - return builder.toString(); - } - - public void restoreState(String saveState) { - if (!TextUtils.isEmpty(saveState)) { - collapseAll(); - final String[] openNodesArray = saveState.split(NODES_PATH_SEPARATOR); - final Set openNodes = new HashSet<>(Arrays.asList(openNodesArray)); - restoreNodeState(mRoot, openNodes); - } - } - - public void toggleNode(TreeNode node) { - if (node.isExpanded()) { - collapseNode(node, false); - } else { - expandNode(node, false); - } - } - - public void collapseNode(TreeNode node, final boolean includeSubnodes) { - node.setExpanded(false); - TreeNode.BaseNodeViewHolder nodeViewHolder = getViewHolderForNode(node); - nodeViewHolder.getNodeItemsView().setVisibility(View.GONE); - nodeViewHolder.toggle(false); - if (includeSubnodes) { - List children = node.getChildren(); - for (int i = 0; i < children.size(); i++) { - TreeNode n = children.get(i); - collapseNode(n, true); - } - } - } - - public void expandNode(final TreeNode node, boolean includeSubnodes) { - node.setExpanded(true); - final TreeNode.BaseNodeViewHolder parentViewHolder = getViewHolderForNode(node); - parentViewHolder.getNodeItemsView().removeAllViews(); - - parentViewHolder.toggle(true); - - List children = node.getChildren(); - for (int i = 0; i < children.size(); i++) { - TreeNode n = children.get(i); - addNode(parentViewHolder.getNodeItemsView(), n); - - if (n.isExpanded() || includeSubnodes) { - expandNode(n, includeSubnodes); - } - } - parentViewHolder.getNodeItemsView().setVisibility(View.VISIBLE); - } - - @SuppressWarnings("unchecked") - public List getSelectedValues(Class clazz) { - List result = new ArrayList<>(); - List selected = getSelected(); - for (TreeNode n : selected) { - Object value = n.getValue(); - if (value != null && value.getClass().equals(clazz)) { - result.add((E) value); - } - } - return result; - } - - public boolean isSelectionModeEnabled() { - return mSelectionModeEnabled; - } - - // ------------------------------------------------------------ - // Selection methods - - public void setSelectionModeEnabled(boolean selectionModeEnabled) { - if (!selectionModeEnabled) { - // TODO fix double iteration over tree - deselectAll(); - } - mSelectionModeEnabled = selectionModeEnabled; - - List children = mRoot.getChildren(); - for (int i = 0; i < children.size(); i++) { - TreeNode node = children.get(i); - toggleSelectionMode(node, selectionModeEnabled); - } - } - - public List getSelected() { - if (mSelectionModeEnabled) { - return getSelected(mRoot); - } else { - return new ArrayList(); - } - } - - public void selectAll(boolean skipCollapsed) { - makeAllSelection(true, skipCollapsed); - } - - public void deselectAll() { - makeAllSelection(false, false); - } - - public void selectNode(TreeNode node, boolean selected) { - if (mSelectionModeEnabled) { - node.setSelected(selected); - toogleSelectionForNode(node, true); - } - } - - private void toogleSelectionForNode(TreeNode node, boolean makeSelectable) { - TreeNode.BaseNodeViewHolder holder = getViewHolderForNode(node); - if (holder.isInitialized()) { - getViewHolderForNode(node).toggleSelectionMode(makeSelectable); - } - } - - private TreeNode.BaseNodeViewHolder getViewHolderForNode(TreeNode node) { - TreeNode.BaseNodeViewHolder viewHolder = node.getViewHolder(); - if (viewHolder == null) { - viewHolder = defaultViewHolder; - } - if (viewHolder.getContainerStyle() <= 0) { - viewHolder.setContainerStyle(containerStyle); - } - if (viewHolder.getTreeView() == null) { - viewHolder.setTreeViev(this); - } - return viewHolder; - } - - public void addNode(TreeNode parent, final TreeNode nodeToAdd) { - parent.addChild(nodeToAdd); - if (parent.isExpanded()) { - final TreeNode.BaseNodeViewHolder parentViewHolder = getViewHolderForNode(parent); - addNode(parentViewHolder.getNodeItemsView(), nodeToAdd); - } - } - - public void removeNode(TreeNode node) { - if (node.getParent() != null) { - TreeNode parent = node.getParent(); - int index = parent.deleteChild(node); - if (parent.isExpanded() && index >= 0) { - final TreeNode.BaseNodeViewHolder parentViewHolder = getViewHolderForNode(parent); - parentViewHolder.getNodeItemsView().removeViewAt(index); - } - } - } - - private void expandLevel(TreeNode node, int level) { - if (node.getLevel() <= level) { - expandNode(node, false); - } - List children = node.getChildren(); - for (int i = 0, childrenSize = children.size(); i < childrenSize; i++) { - TreeNode n = children.get(i); - expandLevel(n, level); - } - } - - private void restoreNodeState(TreeNode node, Set openNodes) { - List children = node.getChildren(); - for (int i = 0, childrenSize = children.size(); i < childrenSize; i++) { - TreeNode n = children.get(i); - if (openNodes.contains(n.getPath())) { - expandNode(n); - restoreNodeState(n, openNodes); - } - } - } - - private void getSaveState(TreeNode root, StringBuilder sBuilder) { - List children = root.getChildren(); - for (int i = 0, childrenSize = children.size(); i < childrenSize; i++) { - TreeNode node = children.get(i); - if (node.isExpanded()) { - sBuilder.append(node.getPath()); - sBuilder.append(NODES_PATH_SEPARATOR); - getSaveState(node, sBuilder); - } - } - } - - @SuppressLint("ClickableViewAccessibility") - private void addNode(ViewGroup container, final TreeNode n) { - final TreeNode.BaseNodeViewHolder viewHolder = getViewHolderForNode(n); - final View nodeView = viewHolder.getView(); - nodeView.setBackgroundResource(nodeViewBackground); - if (nodeView.getParent() != null && nodeView.getParent() instanceof ViewGroup) { - ((ViewGroup) nodeView.getParent()).removeView(nodeView); - } - container.addView(nodeView); - if (mSelectionModeEnabled) { - viewHolder.toggleSelectionMode(true); - } - - nodeView.setOnClickListener(v -> handleNodeClick(n)); - nodeView.setOnLongClickListener(v -> handleNodeLongClick(n)); - nodeView.setOnTouchListener(new NodeTouchHandler(n, nodeView, nodeDragListener)); - } - - private void toggleSelectionMode(TreeNode parent, boolean mSelectionModeEnabled) { - toogleSelectionForNode(parent, mSelectionModeEnabled); - if (parent.isExpanded()) { - List children = parent.getChildren(); - for (int i = 0; i < children.size(); i++) { - TreeNode node = children.get(i); - toggleSelectionMode(node, mSelectionModeEnabled); - } - } - } - - private void handleNodeClick(TreeNode n) { - if (n.getClickListener() != null) n.getClickListener().onClick(n, n.getValue()); - else if (nodeClickListener != null) nodeClickListener.onClick(n, n.getValue()); - if (enableAutoToggle) toggleNode(n); - } - - private boolean handleNodeLongClick(TreeNode n) { - if (n.getLongClickListener() != null) return n.getLongClickListener().onLongClick(n, n.getValue()); - if (nodeLongClickListener != null) return nodeLongClickListener.onLongClick(n, n.getValue()); - if (enableAutoToggle) toggleNode(n); - return false; - } - - // TODO Do we need to go through whole tree? Save references or consider collapsed nodes as not - // selected - private List getSelected(TreeNode parent) { - List result = new ArrayList<>(); - List children = parent.getChildren(); - for (int i = 0; i < children.size(); i++) { - TreeNode n = children.get(i); - if (n.isSelected()) { - result.add(n); - } - result.addAll(getSelected(n)); - } - return result; - } - - // ----------------------------------------------------------------- - // Add / Remove - - private void makeAllSelection(boolean selected, boolean skipCollapsed) { - if (mSelectionModeEnabled) { - List children = mRoot.getChildren(); - for (int i = 0; i < children.size(); i++) { - TreeNode node = children.get(i); - selectNode(node, selected, skipCollapsed); - } - } - } - - private void selectNode(TreeNode parent, boolean selected, boolean skipCollapsed) { - parent.setSelected(selected); - toogleSelectionForNode(parent, true); - boolean toContinue = !skipCollapsed || parent.isExpanded(); - if (toContinue) { - List children = parent.getChildren(); - for (int i = 0; i < children.size(); i++) { - TreeNode node = children.get(i); - selectNode(node, selected, skipCollapsed); - } - } - } + parentViewHolder.toggle(true); + + List children = node.getChildren(); + for (int i = 0; i < children.size(); i++) { + TreeNode n = children.get(i); + addNode(parentViewHolder.getNodeItemsView(), n); + + if (n.isExpanded() || includeSubnodes) { + expandNode(n, includeSubnodes); + } + } + parentViewHolder.getNodeItemsView().setVisibility(View.VISIBLE); + } + + @SuppressWarnings("unchecked") + public List getSelectedValues(Class clazz) { + List result = new ArrayList<>(); + List selected = getSelected(); + for (TreeNode n : selected) { + Object value = n.getValue(); + if (value != null && value.getClass().equals(clazz)) { + result.add((E) value); + } + } + return result; + } + + public boolean isSelectionModeEnabled() { + return mSelectionModeEnabled; + } + + // ------------------------------------------------------------ + // Selection methods + + public void setSelectionModeEnabled(boolean selectionModeEnabled) { + if (!selectionModeEnabled) { + // TODO fix double iteration over tree + deselectAll(); + } + mSelectionModeEnabled = selectionModeEnabled; + + List children = mRoot.getChildren(); + for (int i = 0; i < children.size(); i++) { + TreeNode node = children.get(i); + toggleSelectionMode(node, selectionModeEnabled); + } + } + + public List getSelected() { + if (mSelectionModeEnabled) { + return getSelected(mRoot); + } else { + return new ArrayList(); + } + } + + public void selectAll(boolean skipCollapsed) { + makeAllSelection(true, skipCollapsed); + } + + public void deselectAll() { + makeAllSelection(false, false); + } + + public void selectNode(TreeNode node, boolean selected) { + if (mSelectionModeEnabled) { + node.setSelected(selected); + toogleSelectionForNode(node, true); + } + } + + private void toogleSelectionForNode(TreeNode node, boolean makeSelectable) { + TreeNode.BaseNodeViewHolder holder = getViewHolderForNode(node); + if (holder.isInitialized()) { + getViewHolderForNode(node).toggleSelectionMode(makeSelectable); + } + } + + private TreeNode.BaseNodeViewHolder getViewHolderForNode(TreeNode node) { + TreeNode.BaseNodeViewHolder viewHolder = node.getViewHolder(); + if (viewHolder == null) { + viewHolder = defaultViewHolder; + } + if (viewHolder.getContainerStyle() <= 0) { + viewHolder.setContainerStyle(containerStyle); + } + if (viewHolder.getTreeView() == null) { + viewHolder.setTreeViev(this); + } + return viewHolder; + } + + public void addNode(TreeNode parent, final TreeNode nodeToAdd) { + parent.addChild(nodeToAdd); + if (parent.isExpanded()) { + final TreeNode.BaseNodeViewHolder parentViewHolder = getViewHolderForNode(parent); + addNode(parentViewHolder.getNodeItemsView(), nodeToAdd); + } + } + + public void removeNode(TreeNode node) { + if (node.getParent() != null) { + TreeNode parent = node.getParent(); + int index = parent.deleteChild(node); + if (parent.isExpanded() && index >= 0) { + final TreeNode.BaseNodeViewHolder parentViewHolder = getViewHolderForNode(parent); + parentViewHolder.getNodeItemsView().removeViewAt(index); + } + } + } + + private void expandLevel(TreeNode node, int level) { + if (node.getLevel() <= level) { + expandNode(node, false); + } + List children = node.getChildren(); + for (int i = 0, childrenSize = children.size(); i < childrenSize; i++) { + TreeNode n = children.get(i); + expandLevel(n, level); + } + } + + private void restoreNodeState(TreeNode node, Set openNodes) { + List children = node.getChildren(); + for (int i = 0, childrenSize = children.size(); i < childrenSize; i++) { + TreeNode n = children.get(i); + if (openNodes.contains(n.getPath())) { + expandNode(n); + restoreNodeState(n, openNodes); + } + } + } + + private void getSaveState(TreeNode root, StringBuilder sBuilder) { + List children = root.getChildren(); + for (int i = 0, childrenSize = children.size(); i < childrenSize; i++) { + TreeNode node = children.get(i); + if (node.isExpanded()) { + sBuilder.append(node.getPath()); + sBuilder.append(NODES_PATH_SEPARATOR); + getSaveState(node, sBuilder); + } + } + } + + @SuppressLint("ClickableViewAccessibility") + private void addNode(ViewGroup container, final TreeNode n) { + final TreeNode.BaseNodeViewHolder viewHolder = getViewHolderForNode(n); + final View nodeView = viewHolder.getView(); + nodeView.setBackgroundResource(nodeViewBackground); + if (nodeView.getParent() != null && nodeView.getParent() instanceof ViewGroup) { + ((ViewGroup) nodeView.getParent()).removeView(nodeView); + } + container.addView(nodeView); + if (mSelectionModeEnabled) { + viewHolder.toggleSelectionMode(true); + } + + nodeView.setOnClickListener(v -> handleNodeClick(n)); + nodeView.setOnLongClickListener(v -> handleNodeLongClick(n)); + nodeView.setOnTouchListener(new NodeTouchHandler(n, nodeView, nodeDragListener)); + } + + private void toggleSelectionMode(TreeNode parent, boolean mSelectionModeEnabled) { + toogleSelectionForNode(parent, mSelectionModeEnabled); + if (parent.isExpanded()) { + List children = parent.getChildren(); + for (int i = 0; i < children.size(); i++) { + TreeNode node = children.get(i); + toggleSelectionMode(node, mSelectionModeEnabled); + } + } + } + + private void handleNodeClick(TreeNode n) { + if (n.getClickListener() != null) n.getClickListener().onClick(n, n.getValue()); + else if (nodeClickListener != null) nodeClickListener.onClick(n, n.getValue()); + if (enableAutoToggle) toggleNode(n); + } + + private boolean handleNodeLongClick(TreeNode n) { + if (n.getLongClickListener() != null) + return n.getLongClickListener().onLongClick(n, n.getValue()); + if (nodeLongClickListener != null) + return nodeLongClickListener.onLongClick(n, n.getValue()); + if (enableAutoToggle) toggleNode(n); + return false; + } + + // TODO Do we need to go through whole tree? Save references or consider collapsed nodes as not + // selected + private List getSelected(TreeNode parent) { + List result = new ArrayList<>(); + List children = parent.getChildren(); + for (int i = 0; i < children.size(); i++) { + TreeNode n = children.get(i); + if (n.isSelected()) { + result.add(n); + } + result.addAll(getSelected(n)); + } + return result; + } + + // ----------------------------------------------------------------- + // Add / Remove + + private void makeAllSelection(boolean selected, boolean skipCollapsed) { + if (mSelectionModeEnabled) { + List children = mRoot.getChildren(); + for (int i = 0; i < children.size(); i++) { + TreeNode node = children.get(i); + selectNode(node, selected, skipCollapsed); + } + } + } + + private void selectNode(TreeNode parent, boolean selected, boolean skipCollapsed) { + parent.setSelected(selected); + toogleSelectionForNode(parent, true); + boolean toContinue = !skipCollapsed || parent.isExpanded(); + if (toContinue) { + List children = parent.getChildren(); + for (int i = 0; i < children.size(); i++) { + TreeNode node = children.get(i); + selectNode(node, selected, skipCollapsed); + } + } + } } diff --git a/treeview/src/main/java/com/unnamed/b/atv/view/NodeTouchHandler.java b/treeview/src/main/java/com/unnamed/b/atv/view/NodeTouchHandler.java index 6d3a3e9f99..c83f476a68 100644 --- a/treeview/src/main/java/com/unnamed/b/atv/view/NodeTouchHandler.java +++ b/treeview/src/main/java/com/unnamed/b/atv/view/NodeTouchHandler.java @@ -61,8 +61,8 @@ private void handleMove() { private void dispatchDrag() { TreeNode.TreeNodeDragListener listener = node.getDragListener() != null - ? node.getDragListener() - : defaultDragListener; + ? node.getDragListener() + : defaultDragListener; if (listener != null) { view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);