Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -24,86 +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<File> {

private LayoutFiletreeItemBinding binding;
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
public View createNodeView(TreeNode node, File file) {
this.binding = LayoutFiletreeItemBinding.inflate(LayoutInflater.from(context));

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);

final var root = applyPadding(node, binding, dp15);

public FileTreeViewHolder(Context context) {
super(context);
}
if (isDir) {
chevron.setVisibility(View.VISIBLE);
updateChevronIcon(node.isExpanded());
} else {
chevron.setVisibility(View.INVISIBLE);
}

@Override
public View createNodeView(TreeNode node, File file) {
this.binding = LayoutFiletreeItemBinding.inflate(LayoutInflater.from(context));
if (externalDropHandler != null) {
externalDropHandler.onNodeBound(node, file, root);
}

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);
return root;
}

final var root = applyPadding(node, binding, dp15);
private void updateChevronIcon(boolean expanded) {
final int chevronIcon;
if (expanded) {
chevronIcon = R.drawable.ic_chevron_down;
} else {
chevronIcon = R.drawable.ic_chevron_right;
}

if (isDir) {
chevron.setVisibility(View.VISIBLE);
updateChevronIcon(node.isExpanded());
} else {
chevron.setVisibility(View.INVISIBLE);
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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
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() }
.map { it.toUri() }
.filter { it.scheme == ContentResolver.SCHEME_CONTENT || it.scheme == ContentResolver.SCHEME_FILE }
.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
}
93 changes: 93 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,93 @@
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 Failed(val error: FileDragError) : FileDragResult
}

sealed interface FileDragError {
data object FileNotFound : FileDragError
data object NotAFile : FileDragError
data object SystemRejected : FileDragError
data class Exception(val throwable: Throwable) : FileDragError
}

class FileDragStarter(
private val context: Context,
) {

fun startDrag(sourceView: View, file: File): FileDragResult {
if (!file.exists()) {
return FileDragResult.Failed(FileDragError.FileNotFound)
}

if (!file.isFile) {
return FileDragResult.Failed(FileDragError.NotAFile)
}

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 = { started ->
if (started) FileDragResult.Started
else FileDragResult.Failed(FileDragError.SystemRejected)
},
onFailure = { throwable ->
FileDragResult.Failed(FileDragError.Exception(throwable))
},
)
}

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 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
}
}
Loading
Loading