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
@@ -0,0 +1,21 @@
package com.ai.assistance.operit.core.files.transfer

import java.util.UUID
import java.util.concurrent.ConcurrentHashMap

internal class FileCopyObserver(
val onProgress: (FileCopyProgress) -> Unit,
val checkCancelled: () -> Unit,
)

/** 工具执行跨越同步入口和 IO 协程;用一次性令牌关联进度,避免并行粘贴串到同一个全局状态。 */
internal object FileCopyObservers {
const val PARAMETER = "__workspace_copy_id"
private val observers = ConcurrentHashMap<String, FileCopyObserver>()

fun register(observer: FileCopyObserver): String = UUID.randomUUID().toString().also { observers[it] = observer }

fun find(id: String?): FileCopyObserver? = id?.let(observers::get)

fun remove(id: String) { observers.remove(id) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.ai.assistance.operit.core.files.transfer

import android.content.Context
import com.ai.assistance.operit.terminal.provider.filesystem.PRootMountMapping
import java.io.File

/** 与终端共用软挂载映射,不能把 /sdcard 简单拼接到 Ubuntu rootfs 下。 */
internal object LocalTransferPaths {
fun resolve(context: Context, path: String, environment: String): File {
if (environment.equals("android", ignoreCase = true)) return File(path)
require(environment.equals("linux", ignoreCase = true)) { "Not a local file environment: $environment" }
val expanded = when {
path == "~" -> "/root"
path.startsWith("~/") -> "/root/" + path.substring(2)
else -> path
}
return File(PRootMountMapping.mapLinuxPathToHostPath(
linuxPath = expanded,
ubuntuRoot = File(context.filesDir, "usr/var/lib/proot-distro/installed-rootfs/ubuntu"),
homeDir = context.filesDir.absolutePath,
appDataDir = context.applicationInfo.dataDir,
packageName = context.packageName,
chrootEnabled = context.getSharedPreferences("terminal_settings", Context.MODE_PRIVATE)
.getBoolean("chroot_enabled", false),
))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package com.ai.assistance.operit.core.files.transfer

import java.io.File
import java.io.IOException
import java.nio.file.FileAlreadyExistsException
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.Path
import java.nio.file.OpenOption
import java.nio.file.SimpleFileVisitor
import java.nio.file.StandardCopyOption
import java.nio.file.StandardOpenOption
import java.nio.file.attribute.BasicFileAttributes

internal enum class FileCopyPhase { SCANNING, COPYING, COMPLETED }

internal data class FileCopyProgress(
val phase: FileCopyPhase,
val currentFile: String,
val copiedBytes: Long,
val totalBytes: Long?,
val copiedFiles: Long,
val totalFiles: Long,
val bytesPerSecond: Double?,
) {
val fraction: Float?
get() = when {
phase == FileCopyPhase.COMPLETED -> 1f
phase == FileCopyPhase.SCANNING || totalBytes == null -> null
totalBytes > 0 -> (copiedBytes.toDouble() / totalBytes).toFloat().coerceIn(0f, 0.999f)
totalFiles > 0 -> (copiedFiles.toDouble() / totalFiles).toFloat().coerceIn(0f, 0.999f)
else -> 0f
}

val remainingBytes: Long?
get() = totalBytes?.let { (it - copiedBytes).coerceAtLeast(0L) }
}

/** 两次逐层遍历分别统计和复制,不缓存整棵目录树,也不将文件内容转成字符串。 */
internal object StreamingFileCopy {
const val BUFFER_SIZE = 256 * 1024
private const val REPORT_INTERVAL_NANOS = 200_000_000L
private const val SPEED_WINDOW_NANOS = 500_000_000L

fun copy(
source: File,
destination: File,
recursive: Boolean = true,
overwrite: Boolean = false,
onProgress: (FileCopyProgress) -> Unit = {},
checkCancelled: () -> Unit = {},
): FileCopyProgress {
val sourcePath = source.absoluteFile.toPath().normalize()
val destinationPath = destination.absoluteFile.toPath().normalize()
val attributes = Files.readAttributes(sourcePath, BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS)
val realSource = source.canonicalFile.toPath()
val realDestination = destination.canonicalFile.toPath()
require(realSource != realDestination && (!attributes.isDirectory || !realDestination.startsWith(realSource))) {
"Cannot copy a path onto itself or into its own directory"
}
require(recursive || !attributes.isDirectory) { "Cannot copy a directory without recursive=true" }
if (!overwrite && Files.exists(destinationPath, LinkOption.NOFOLLOW_LINKS)) {
throw FileAlreadyExistsException(destination.path)
}

var phase = FileCopyPhase.SCANNING
var currentFile = source.name
var totalBytes = 0L
var totalFiles = 0L
var copiedBytes = 0L
var copiedFiles = 0L
var lastReportedAt = System.nanoTime()
var windowStartedAt = lastReportedAt
var windowStartedBytes = 0L
var speed: Double? = null

fun report(force: Boolean = false): FileCopyProgress? {
checkCancelled()
val now = System.nanoTime()
if (phase == FileCopyPhase.COPYING && now - windowStartedAt >= SPEED_WINDOW_NANOS) {
speed = (copiedBytes - windowStartedBytes) * 1_000_000_000.0 / (now - windowStartedAt)
windowStartedAt = now
windowStartedBytes = copiedBytes
}
if (!force && now - lastReportedAt < REPORT_INTERVAL_NANOS) return null
lastReportedAt = now
return FileCopyProgress(
phase, currentFile, copiedBytes,
totalBytes.takeUnless { phase == FileCopyPhase.SCANNING }, totalFiles = totalFiles,
copiedFiles = copiedFiles, bytesPerSecond = speed,
).also(onProgress)
}

fun select(path: Path) {
checkCancelled()
currentFile = sourcePath.relativize(path).toString().ifEmpty { source.name }
}

report(force = true)
Files.walkFileTree(sourcePath, object : SimpleFileVisitor<Path>() {
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
select(dir)
report()
return FileVisitResult.CONTINUE
}

override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
select(file)
require(attrs.isRegularFile || attrs.isSymbolicLink) { "Unsupported file type: $file" }
totalFiles = Math.addExact(totalFiles, 1L)
if (attrs.isRegularFile) totalBytes = Math.addExact(totalBytes, attrs.size())
report()
return FileVisitResult.CONTINUE
}
})

phase = FileCopyPhase.COPYING
windowStartedAt = System.nanoTime()
windowStartedBytes = 0L
report(force = true)
if (attributes.isDirectory && !overwrite) {
destinationPath.parent?.let { Files.createDirectories(it) }
// 原子创建顶层目标,统计期间出现同名目录时也不能合并写入。
Files.createDirectory(destinationPath)
}
// 整个任务复用这一块缓冲区,内存占用不随文件大小或文件数量增长。
val buffer = ByteArray(BUFFER_SIZE)
Files.walkFileTree(sourcePath, object : SimpleFileVisitor<Path>() {
private fun target(path: Path): Path = destinationPath.resolve(sourcePath.relativize(path))

override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
select(dir)
Files.createDirectories(target(dir))
report()
return FileVisitResult.CONTINUE
}

override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
select(file)
val target = target(file)
target.parent?.let { Files.createDirectories(it) }
if (attrs.isSymbolicLink) {
// 不遍历链接目标,避免目录环和将链接指向的外部目录一并复制。
if (overwrite) Files.copy(file, target, LinkOption.NOFOLLOW_LINKS, StandardCopyOption.REPLACE_EXISTING)
else Files.copy(file, target, LinkOption.NOFOLLOW_LINKS)
} else {
require(attrs.isRegularFile) { "Unsupported file type: $file" }
val options = if (overwrite) {
arrayOf<OpenOption>(StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)
} else {
arrayOf<OpenOption>(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)
}
var fileBytes = 0L
Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS).use { input ->
Files.newOutputStream(target, *options).use { output ->
while (true) {
checkCancelled()
val count = input.read(buffer)
if (count < 0) break
if (count == 0) continue
output.write(buffer, 0, count)
fileBytes += count
copiedBytes += count
report()
}
}
}
if (fileBytes != attrs.size()) throw IOException("Source size changed while copying: $file")
}
copiedFiles++
report()
return FileVisitResult.CONTINUE
}
})
if (copiedBytes != totalBytes || copiedFiles != totalFiles) {
throw IOException("Source directory changed while copying: $source")
}
phase = FileCopyPhase.COMPLETED
return checkNotNull(report(force = true))
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
package com.ai.assistance.operit.core.tools.defaultTool.standard

import android.content.Context
import com.ai.assistance.operit.core.files.transfer.FileCopyObserver
import com.ai.assistance.operit.core.files.transfer.FileCopyObservers
import com.ai.assistance.operit.core.files.transfer.LocalTransferPaths
import com.ai.assistance.operit.core.files.transfer.StreamingFileCopy
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import com.ai.assistance.operit.R
import com.ai.assistance.operit.util.logging.AppLogger
import com.ai.assistance.operit.api.chat.EnhancedAIService
Expand Down Expand Up @@ -2417,30 +2424,52 @@ open class StandardFileSystemTools(protected val context: Context) {
}
}

/** Helper method to recursively copy a directory */
private fun copyDirectory(sourceDir: File, destDir: File): Boolean {
try {
if (!destDir.exists()) {
destDir.mkdirs()
}

sourceDir.listFiles()?.forEach { file ->
val destFile = File(destDir, file.name)
if (file.isDirectory) {
copyDirectory(file, destFile)
} else {
file.inputStream().use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
}
}
/** 目录内任一项失败都必须失败,避免移动操作在不完整复制后删除来源。 */
private fun copyDirectory(sourceDir: File, destDir: File): Boolean = try {
StreamingFileCopy.copy(sourceDir, destDir, overwrite = true)
true
} catch (e: Exception) {
AppLogger.e(TAG, "Error copying directory", e)
false
}

return true
/** 本机复制直接连接两个文件流,保持二进制内容并提供任务独立的进度。 */
private suspend fun copyLocalFile(
toolName: String,
sourcePath: String,
destPath: String,
sourceEnvironment: String,
destEnvironment: String,
recursive: Boolean,
observer: FileCopyObserver?,
): ToolResult = withContext(Dispatchers.IO) {
try {
val executionContext = currentCoroutineContext()
val progress = StreamingFileCopy.copy(
source = LocalTransferPaths.resolve(context, sourcePath, sourceEnvironment),
destination = LocalTransferPaths.resolve(context, destPath, destEnvironment),
recursive = recursive,
overwrite = observer == null,
onProgress = { observer?.onProgress?.invoke(it) },
checkCancelled = {
executionContext.ensureActive()
observer?.checkCancelled?.invoke()
},
)
ToolResult(
toolName = toolName,
success = true,
result = FileOperationData(operation = "copy", path = sourcePath, successful = true,
details = "Copied $sourceEnvironment:$sourcePath to $destEnvironment:$destPath (${progress.copiedBytes} bytes)"),
error = "",
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
AppLogger.e(TAG, "Error copying directory", e)
return false
AppLogger.e(TAG, "Error streaming local file copy", e)
ToolResult(toolName = toolName, success = false,
result = FileOperationData(operation = "copy", path = sourcePath, successful = false, details = e.message.orEmpty()),
error = "Failed to copy $sourcePath: ${e.message}")
}
}

Expand Down Expand Up @@ -2704,7 +2733,7 @@ open class StandardFileSystemTools(protected val context: Context) {
if (result.success) {
copiedDirs++
} else {
AppLogger.w(TAG, "Failed to copy directory: $srcFullPath")
return result
}
} else {
val result = copyFileCrossEnvironment(
Expand All @@ -2718,7 +2747,7 @@ open class StandardFileSystemTools(protected val context: Context) {
if (result.success) {
copiedFiles++
} else {
AppLogger.w(TAG, "Failed to copy file: $srcFullPath")
return result
}
}
}
Expand Down Expand Up @@ -2799,6 +2828,23 @@ open class StandardFileSystemTools(protected val context: Context) {
)
}

val observer = FileCopyObservers.find(tool.parameters.find { it.name == FileCopyObservers.PARAMETER }?.value)
val localEnvironments = listOf(srcEnv, dstEnv).all {
it.equals("android", ignoreCase = true) || it.equals("linux", ignoreCase = true)
}
val usesLinux = isLinuxEnvironment(srcEnv) || isLinuxEnvironment(dstEnv)
// 按实际后端选择传输:SSH 保持远程提供方,本机路径使用终端相同的软挂载映射。
if (localEnvironments && (!usesLinux || !isSshFileSystemActive()) &&
(usesLinux || observer != null)) {
if (!isLinuxEnvironment(srcEnv)) {
PathValidator.validateAndroidPath(sourcePath, tool.name, "source")?.let { return it }
}
if (!isLinuxEnvironment(dstEnv)) {
PathValidator.validateAndroidPath(destPath, tool.name, "destination")?.let { return it }
}
return copyLocalFile(tool.name, sourcePath, destPath, srcEnv, dstEnv, recursive, observer)
}

// 检查是否是跨环境复制
val isCrossEnvironment = srcEnv.lowercase() != dstEnv.lowercase()

Expand Down
Loading