Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,20 @@

public class FileTreeViewHolder extends TreeNode.BaseNodeViewHolder<File> {

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
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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).toImportableExternalUris(context).isNotEmpty()
}
}
else -> clipDescription?.hasImportableMimeType() == true
}
}

/**
* Resolves the [ClipData.Item] to a list of external [Uri]s, ignoring internal application URIs.
*/
fun ClipData.Item.toImportableExternalUris(context: Context): List<Uri> {
return toExternalUris().filterNot { it.isInternalDragUri(context) }
}

private fun Uri.isInternalDragUri(context: Context): Boolean {
return authority == "${context.packageName}.providers.fileprovider"
}

private fun ClipData.Item.toExternalUris(): List<Uri> {
uri?.let { return listOf(it) }

val textContent = text?.toString() ?: return emptyList()

return textContent.lineSequence()
.map { it.trim() }
.filter { it.startsWith("content://") || it.startsWith("file://") }
Comment thread
jatezzz marked this conversation as resolved.
Outdated
.map { it.toUri() }
.toList()
}

private fun ClipDescription.hasImportableMimeType(): Boolean {
return hasMimeType(ClipDescription.MIMETYPE_TEXT_URILIST) ||
hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) ||
hasMimeType("*/*")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
100 changes: 100 additions & 0 deletions app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt
Original file line number Diff line number Diff line change
@@ -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
Comment thread
jatezzz marked this conversation as resolved.
Outdated
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
}
}
Original file line number Diff line number Diff line change
@@ -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({
Comment thread
jatezzz marked this conversation as resolved.
Outdated
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)
}
}
Loading
Loading