Skip to content

Commit d21fcec

Browse files
committed
fix(filetree): improve drag robustness, handle partial imports gracefully, and secure file handling
1 parent 6dccdc3 commit d21fcec

File tree

5 files changed

+79
-38
lines changed

5 files changed

+79
-38
lines changed

app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeDropController.kt

Lines changed: 66 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.itsaky.androidide.fragments.sidebar
33
import android.app.Activity
44
import android.content.ClipData
55
import android.content.Context
6+
import android.graphics.drawable.Drawable
67
import android.net.Uri
78
import android.view.DragEvent
89
import android.view.View
@@ -23,6 +24,11 @@ internal class FileTreeDropController(
2324
) {
2425

2526
private data class DropTarget(val node: TreeNode?, val file: File)
27+
private sealed interface ImportResult {
28+
data class Success(val count: Int) : ImportResult
29+
data class PartialSuccess(val count: Int, val error: Throwable) : ImportResult
30+
data class Failure(val error: Throwable) : ImportResult
31+
}
2632

2733
val nodeBinder = FileTreeViewHolder.ExternalDropHandler { node, file, view ->
2834
bindDropTarget(view, DropTarget(node, file))
@@ -88,33 +94,40 @@ internal class FileTreeDropController(
8894
} finally {
8995
dragPermissions?.release()
9096
}
91-
}) { copiedCount, error ->
92-
if (activity.isFinishing || activity.isDestroyed) {
93-
return@executeAsyncProvideError
94-
}
97+
}) { result, catastrophicError ->
98+
if (activity.isFinishing || activity.isDestroyed) return@executeAsyncProvideError
9599

96-
if (error != null) {
97-
onDropFailed(
98-
error.cause?.message
99-
?: error.message
100-
?: context.getString(R.string.msg_file_tree_drop_import_failed)
101-
)
102-
return@executeAsyncProvideError
103-
}
100+
fun Throwable.toErrorMsg() = cause?.message ?: message ?: context.getString(R.string.msg_file_tree_drop_import_failed)
104101

105-
val importedCount = copiedCount ?: 0
106-
if (importedCount <= 0) {
107-
onDropFailed(context.getString(R.string.msg_file_tree_drop_no_files))
102+
if (catastrophicError != null) {
103+
onDropFailed(catastrophicError.toErrorMsg())
108104
return@executeAsyncProvideError
109105
}
110106

111-
onDropCompleted(target.node, target.file, importedCount)
107+
when (result) {
108+
is ImportResult.Success -> {
109+
if (result.count > 0) {
110+
onDropCompleted(target.node, target.file, result.count)
111+
} else {
112+
onDropFailed(context.getString(R.string.msg_file_tree_drop_no_files))
113+
}
114+
}
115+
is ImportResult.PartialSuccess -> {
116+
onDropCompleted(target.node, target.file, result.count)
117+
onDropFailed(result.error.toErrorMsg())
118+
}
119+
is ImportResult.Failure -> {
120+
onDropFailed(result.error.toErrorMsg())
121+
}
122+
123+
else -> {}
124+
}
112125
}
113126

114127
return true
115128
}
116129

117-
private fun copyDroppedFiles(context: Context, clipData: ClipData, targetFile: File): Int {
130+
private fun copyDroppedFiles(context: Context, clipData: ClipData, targetFile: File): ImportResult {
118131
val targetDirectory = resolveTargetDirectory(targetFile)
119132
require(targetDirectory.exists() && targetDirectory.isDirectory) {
120133
context.getString(R.string.msg_file_tree_drop_destination_missing)
@@ -127,21 +140,35 @@ internal class FileTreeDropController(
127140
continue
128141
}
129142

130-
val sourceName =
131-
uri.getFileName(context).ifBlank { context.getString(R.string.msg_file_tree_drop_default_name) }
132-
val destinationFile = createAvailableTargetFile(targetDirectory, sourceName)
143+
val defaultName = context.getString(R.string.msg_file_tree_drop_default_name)
144+
val rawName = uri.getFileName(context).ifBlank { defaultName }
145+
146+
var sanitizedName = rawName.substringAfterLast('/').substringAfterLast('\\')
133147

134-
UriFileImporter.copyUriToFile(
135-
context = context,
136-
uri = uri,
137-
destinationFile = destinationFile,
138-
onOpenFailed = { IllegalStateException(context.getString(R.string.msg_file_tree_drop_read_failed, sourceName)) },
139-
)
148+
if (sanitizedName == "." || sanitizedName == ".." || sanitizedName.isBlank()) {
149+
sanitizedName = defaultName
150+
}
151+
152+
val destinationFile = createAvailableTargetFile(targetDirectory, sanitizedName)
140153

141-
importedCount++
154+
try {
155+
UriFileImporter.copyUriToFile(
156+
context = context,
157+
uri = uri,
158+
destinationFile = destinationFile,
159+
onOpenFailed = { IllegalStateException(context.getString(R.string.msg_file_tree_drop_read_failed, sanitizedName)) },
160+
)
161+
importedCount++
162+
} catch (e: Exception) {
163+
return if (importedCount > 0) {
164+
ImportResult.PartialSuccess(importedCount, e)
165+
} else {
166+
ImportResult.Failure(e)
167+
}
168+
}
142169
}
143170

144-
return importedCount
171+
return ImportResult.Success(importedCount)
145172
}
146173

147174
private fun resolveTargetDirectory(targetFile: File): File {
@@ -165,15 +192,25 @@ internal class FileTreeDropController(
165192
}
166193

167194
private fun showDropHighlight(view: View) {
195+
if (view.getTag(R.id.filetree_drop_target_tag) == null) {
196+
view.setTag(R.id.filetree_drop_target_tag, view.background ?: "NULL_BG")
197+
}
198+
168199
val baseColor = ContextCompat.getColor(activity, R.color.teal_200)
169200
view.setBackgroundColor((baseColor and 0x00FFFFFF) or (64 shl 24))
170201
}
171202

172203
private fun clearDropHighlight(view: View) {
173-
view.background = null
204+
val savedBg = view.getTag(R.id.filetree_drop_target_tag)
205+
if (savedBg != null) {
206+
view.background = if (savedBg == "NULL_BG") null else savedBg as? Drawable
207+
208+
view.setTag(R.id.filetree_drop_target_tag, null)
209+
}
174210
}
175211

176212
private fun DragEvent.hasDroppedContent(): Boolean {
213+
if (localState != null) return false
177214
return clipDescription != null || (clipData?.itemCount ?: 0) > 0
178215
}
179216

app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,8 +226,7 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener,
226226
try {
227227
val contentUri = FileProvider.getUriForFile(context, authority, file)
228228

229-
val extension = MimeTypeMap.getFileExtensionFromUrl(file.name)
230-
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
229+
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension.lowercase())
231230
?: "application/octet-stream"
232231

233232
val clipData = ClipData(
@@ -239,7 +238,7 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener,
239238
val shadow = View.DragShadowBuilder(view)
240239
val flags = View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ
241240

242-
ViewCompat.startDragAndDrop(view, clipData, shadow, null, flags)
241+
ViewCompat.startDragAndDrop(view, clipData, shadow, true, flags)
243242
} catch (e: Exception) {
244243
e.printStackTrace()
245244
}

app/src/main/java/com/itsaky/androidide/tasks/callables/FileTreeCallable.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ public Boolean call() throws Exception {
5454
}
5555

5656
private void populateChildren(File[] files, TreeNode parent) {
57+
if (files == null) return;
58+
5759
Arrays.sort(files, new SortFileName());
5860
Arrays.sort(files, new SortFolder());
5961
for (File file : files) {

app/src/main/java/com/itsaky/androidide/utils/UriFileImporter.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ object UriFileImporter {
4343
fun getDisplayName(contentResolver: ContentResolver, uri: Uri): String? {
4444
return try {
4545
when (uri.scheme) {
46-
"content" -> queryDisplayName(contentResolver, uri)
46+
"content" -> queryDisplayName(contentResolver, uri) ?: uri.lastPathSegment
4747
"file" -> uri.lastPathSegment
4848
else -> uri.lastPathSegment
4949
}
5050
} catch (e: Exception) {
5151
Log.e(TAG, "Error getting filename from URI", e)
52-
null
52+
uri.lastPathSegment
5353
}
5454
}
5555

git-core/src/main/java/com/itsaky/androidide/git/core/GitRepositoryUrls.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package com.itsaky.androidide.git.core
33
import java.net.URI
44
import java.net.URISyntaxException
55

6-
private val sshGitUrlRegex = Regex("^git@[^\\s:]+:\\S+(?:\\.git)?$")
6+
private val sshGitUrlRegex = Regex("^[^\\s@/]+@[^\\s:/]+:\\S+$")
77
private val supportedGitSchemes = setOf("http", "https", "git", "ssh")
88

99
fun parseGitRepositoryUrl(rawText: String): String? {
@@ -34,9 +34,12 @@ fun parseGitRepositoryUrl(rawText: String): String? {
3434

3535
val path = uri.path ?: ""
3636
val pathSegments = path.split("/").filter { it.isNotBlank() }
37-
val looksLikeRepositoryPath = pathSegments.size >= 2 || path.endsWith(".git")
3837

39-
if (!looksLikeRepositoryPath) {
38+
val isExplicitGitUrl = path.endsWith(".git")
39+
val webUiIndicators = setOf("tree", "blob", "raw", "commits", "commit", "pull", "issues", "releases", "tags", "branches", "-")
40+
val hasWebUiSegments = pathSegments.any { it in webUiIndicators }
41+
42+
if (!isExplicitGitUrl && (pathSegments.size < 2 || hasWebUiSegments || !uri.query.isNullOrBlank())) {
4043
return null
4144
}
4245

@@ -47,7 +50,7 @@ fun parseGitRepositoryUrl(rawText: String): String? {
4750
uri.host,
4851
uri.port,
4952
uri.path,
50-
uri.query,
53+
if (isExplicitGitUrl) uri.query else null,
5154
null
5255
).toString()
5356
} catch (_: URISyntaxException) {

0 commit comments

Comments
 (0)