diff --git a/app/src/main/java/com/aicode/feature/agent/domain/shizuku/ShizukuManager.kt b/app/src/main/java/com/aicode/feature/agent/domain/shizuku/ShizukuManager.kt index ea44ecd07..46d304add 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/shizuku/ShizukuManager.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/shizuku/ShizukuManager.kt @@ -26,10 +26,14 @@ import javax.inject.Singleton /** Shizuku 可用状态,供设置页展示与工具执行前判定。 */ enum class ShizukuState { - /** 未安装 Shizuku(或 Sui)。 */ - NOT_INSTALLED, - - /** 已安装但服务未运行,需用户先在 Shizuku 内启动服务。 */ + /** + * binder 未就绪:服务未启动或已停止,也可能设备上根本没有可用的管理器。 + * + * 以包名判「装没装」必然误判(管理器包名不在官方契约内:Sui 是 Magisk 模块、没有独立管理器包, + * Stellar 等兼容层用各自的包名),所以状态判定只看 binder; + * 「装没装」不进状态机,而由 UI 文案兼顾(未就绪时的提示同时交代「打开管理器」与「没装就去下载」, + * 点击后 [ShizukuManager.openShizukuApp] 找不到管理器就跳下载页)。 + */ NOT_RUNNING, /** 服务运行中,但本应用尚未获得授权。 */ @@ -42,6 +46,19 @@ enum class ShizukuState { /** 一次 Shizuku 命令执行结果。[exitCode] 为负值表示超时或启动异常。 */ data class ShizukuCommandResult(val output: String, val exitCode: Int) +/** + * Shizuku 服务端的只读身份信息,供设置页展示「当前连的是谁」。 + * + * [version] 是服务端 API 版本,服务端未应答时为 -1;[seLinuxContext] 拿不到时为 null。 + */ +data class ShizukuPeerInfo( + val uid: Int, + val version: Int, + val seLinuxContext: String?, + val isAdb: Boolean, + val isRoot: Boolean +) + /** * Shizuku 后端:以 adb shell(uid 2000)身份执行命令。 * @@ -57,9 +74,25 @@ class ShizukuManager @Inject constructor( private companion object { const val TAG = "ShizukuManager" - /** Shizuku 官方应用包名(Sui 也用它作为入口)。 */ + /** Shizuku 官方应用包名。 */ const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" + /** Stellar:兼容 Shizuku 的管理器,包名与官方不同,binder 协议一致。 */ + const val STELLAR_PACKAGE = "roro.stellar.manager" + + /** 已知管理器包名,按尝试顺序排列;都没命中时退回 provider authority 扫描。 */ + val KNOWN_MANAGER_PACKAGES = listOf(SHIZUKU_PACKAGE, STELLAR_PACKAGE) + + /** 管理器 provider authority 的命名后缀约定:官方用 `.shizuku`,Stellar 用 `.stellar`。 */ + const val SHIZUKU_PROVIDER_SUFFIX = ".shizuku" + const val STELLAR_PROVIDER_SUFFIX = ".stellar" + + /** adb shell 身份对应的 Linux uid。 */ + const val UID_SHELL = 2000 + + /** root 身份对应的 Linux uid。 */ + const val UID_ROOT = 0 + const val PERMISSION_REQUEST_CODE = 1001 /** 服务身份 tag:不设时用类名,类名经 R8 混淆后不稳定,故显式固定。 */ @@ -74,7 +107,7 @@ class ShizukuManager @Inject constructor( const val DOWNLOAD_URL = "https://shizuku.rikka.app/download/" } - private val _state = MutableStateFlow(ShizukuState.NOT_INSTALLED) + private val _state = MutableStateFlow(ShizukuState.NOT_RUNNING) val state: StateFlow = _state.asStateFlow() private val bindMutex = Mutex() @@ -157,22 +190,45 @@ class ShizukuManager @Inject constructor( _state.value = computeState() } - private fun computeState(): ShizukuState { - if (!isShizukuInstalled()) return ShizukuState.NOT_INSTALLED - if (!runCatching { Shizuku.pingBinder() }.getOrDefault(false)) return ShizukuState.NOT_RUNNING - // pre-v11 无 UserService,等同不可用。 - if (runCatching { Shizuku.isPreV11() }.getOrDefault(false)) return ShizukuState.NOT_RUNNING - val granted = runCatching { - Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED - }.getOrDefault(false) - return if (granted) ShizukuState.READY else ShizukuState.PERMISSION_DENIED + /** + * 按 binder 存活与授权结果推导状态。 + * + * 不以管理器包名判「装没装」:官方契约里 binder 由服务端推来(客户端 provider 只收不拉), + * 与管理器叫什么包名无关,而 Sui 与 Stellar 也都不使用官方包名。 + * 任一 binder 调用抛异常(服务恰好在探测途中断开)都按未运行处理,状态机不崩。 + */ + private fun computeState(): ShizukuState = runCatching { + when { + !Shizuku.pingBinder() -> ShizukuState.NOT_RUNNING + // pre-v11 无 UserService,等同不可用。 + Shizuku.isPreV11() -> ShizukuState.NOT_RUNNING + // READY 只由服务端按 uid 校验的授权结果决定,本地判断不参与。 + Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED -> ShizukuState.READY + else -> ShizukuState.PERMISSION_DENIED + } + }.getOrDefault(ShizukuState.NOT_RUNNING) + + /** + * 读取当前服务端的只读身份信息(adb / root、API 版本、SELinux 上下文)。 + * + * 纯查询:不触发授权、不改状态。[state] 不是 [ShizukuState.READY] 时直接返回 null, + * 其余异常(binder 中途断开等)也一并降级为 null。 + */ + fun peerInfo(): ShizukuPeerInfo? { + if (_state.value != ShizukuState.READY) return null + return runCatching { + val uid = Shizuku.getUid() + ShizukuPeerInfo( + uid = uid, + version = Shizuku.getVersion(), + seLinuxContext = Shizuku.getSELinuxContext(), + isAdb = uid == UID_SHELL, + isRoot = uid == UID_ROOT + ) + }.onFailure { FileLogger.w(TAG, "读取 Shizuku 服务端信息失败: ${it.message}") } + .getOrNull() } - private fun isShizukuInstalled(): Boolean = runCatching { - context.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0) - true - }.getOrDefault(false) - /** 申请 Shizuku 授权。需在主线程调用(Shizuku 内部要求)。 */ fun requestPermission() { if (computeState() != ShizukuState.PERMISSION_DENIED) return @@ -180,9 +236,9 @@ class ShizukuManager @Inject constructor( .onFailure { FileLogger.w(TAG, "申请 Shizuku 授权失败: ${it.message}") } } - /** 打开 Shizuku 应用;未安装则跳转官方下载页。 */ + /** 打开管理器应用;一个都找不到时跳转官方下载页。 */ fun openShizukuApp() { - val launch = context.packageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE) + val launch = findManagerLaunchIntent() runCatching { if (launch != null) { launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -196,6 +252,42 @@ class ShizukuManager @Inject constructor( }.onFailure { FileLogger.w(TAG, "打开 Shizuku 失败: ${it.message}") } } + /** + * 找一个可启动的管理器:先按已知包名(官方、Stellar),再按 provider authority 命名约定扫描。 + * + * 扫描只认「包自身声明了 authority 等于 `<包名>.shizuku` 或 `<包名>.stellar`」这一条, + * 且排除本应用自己(自身就声明 `.shizuku`)。authority 必须精确相等, + * 不做前缀或包含匹配,否则抢注相似 authority 的应用能被当成管理器打开。 + */ + private fun findManagerLaunchIntent(): Intent? { + for (pkg in KNOWN_MANAGER_PACKAGES) { + launchIntentFor(pkg)?.let { return it } + } + val discovered = runCatching { findManagerByProviderAuthority() } + .onFailure { FileLogger.w(TAG, "扫描 Shizuku 管理器包失败: ${it.message}") } + .getOrNull() + return discovered?.let { launchIntentFor(it) } + } + + /** 取包名的启动入口;未安装或不可见时返回 null。 */ + private fun launchIntentFor(pkg: String): Intent? = + runCatching { context.packageManager.getLaunchIntentForPackage(pkg) }.getOrNull() + + @Suppress("DEPRECATION") + private fun findManagerByProviderAuthority(): String? { + val self = context.packageName + for (info in context.packageManager.getInstalledPackages(PackageManager.GET_PROVIDERS)) { + val pkg = info.packageName + if (pkg == self) continue + val matched = info.providers.orEmpty().any { provider -> + val authority = provider.authority ?: return@any false + authority == pkg + SHIZUKU_PROVIDER_SUFFIX || authority == pkg + STELLAR_PROVIDER_SUFFIX + } + if (matched) return pkg + } + return null + } + /** 执行 shell 命令。未就绪或绑定失败时抛异常,由调用方转成工具错误。 */ suspend fun runCommand(command: String, timeoutMs: Long): ShizukuCommandResult { val service = ensureBound() diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/file/GenerateImageTool.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/file/GenerateImageTool.kt index 26fee1894..677f22bed 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/tool/file/GenerateImageTool.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/file/GenerateImageTool.kt @@ -9,11 +9,13 @@ import com.aicode.feature.agent.data.remote.openai.ImageGenerationResponse import com.aicode.feature.agent.data.remote.openai.OpenAIApi import com.aicode.feature.agent.domain.model.AgentContext import com.aicode.feature.agent.domain.model.AgentImage +import com.aicode.feature.agent.domain.provider.AllKeysFailedException import com.aicode.feature.agent.domain.provider.enrichWithHttpErrorBody import com.aicode.feature.agent.domain.provider.isKeySwitchFailure import com.aicode.feature.agent.domain.provider.joinUrl import com.aicode.feature.agent.domain.provider.parseInteractionSteps import com.aicode.feature.agent.domain.provider.resolveCustomHeaders +import com.aicode.feature.agent.domain.provider.retryStaircase import com.aicode.feature.agent.domain.tool.AbstractContextualTool import com.aicode.feature.agent.domain.tool.ParameterType import com.aicode.feature.agent.domain.tool.PendingToolPermission @@ -21,6 +23,7 @@ import com.aicode.feature.agent.domain.tool.ToolCapability import com.aicode.feature.agent.domain.tool.ToolParameter import com.aicode.feature.agent.domain.tool.ToolPermissionPolicy import com.aicode.feature.agent.domain.tool.ToolResult +import com.aicode.feature.settings.data.repository.GeneralSettingsRepository import com.aicode.feature.settings.data.repository.ImageGenModelSettingsRepository import com.aicode.feature.settings.data.repository.ProviderKeyRotator import com.aicode.feature.settings.domain.model.ProviderType @@ -61,7 +64,8 @@ class GenerateImageTool @Inject constructor( private val openAIApi: OpenAIApi, private val geminiApi: GeminiApi, private val httpClient: OkHttpClient, - private val keyRotator: ProviderKeyRotator + private val keyRotator: ProviderKeyRotator, + private val generalSettingsRepository: GeneralSettingsRepository ) : AbstractContextualTool() { override val name = "generateImage" @@ -176,10 +180,13 @@ class GenerateImageTool @Inject constructor( var seq = 0 var lastProviderId = "" var activeApiKey = "" + // 本次已用过的 Key(含首次):重试时不再选它。声明在 try 外,失败出口还要用它上报。 + val triedKeys = mutableSetOf() return@withContext try { val provider = resolveImageGenProvider() lastProviderId = provider.id activeApiKey = keyRotator.activeKey(provider, context.sessionId) ?: provider.firstUsableApiKey + triedKeys += activeApiKey val model = provider.effectiveModel // Gemini 官方图像模型走 Interactions 协议(v1beta/interactions),OpenAI 的 // /v1/images/generations 端点打不通;设了 Gemini 生图模型时切独立通道, @@ -219,12 +226,33 @@ class GenerateImageTool @Inject constructor( seq = AILogger.logRequest(context.sessionId, provider.id, model, "POST", url, request) - val response = openAIApi.createImage( - url = url, - authorization = "Bearer $activeApiKey", - extraHeaders = resolveCustomHeaders(provider.customHeaders, context.sessionId, activeApiKey), - request = request - ) + val response = retryStaircase( + maxRetries = imageGenMaxRetries(), + onKeyFailure = { e, _ -> + // 与文本路径同一套判定:先看是否归因于 Key(鉴权/额度/限流),是才切; + // 切完返回 true,retryStaircase 重置重试计数并重发;无可切候选就报「N 个 Key 均失败」。 + if (activeApiKey.isEmpty() || !e.isKeySwitchFailure(provider.effectiveKeySwitchStatusCodes)) { + false + } else { + val switched = keyRotator.reportFailure(provider.id, context.sessionId, activeApiKey, triedKeys) + ?: throw AllKeysFailedException( + "「${provider.name}」的 ${provider.effectiveApiKeys.size} 个 Key 均失败:" + + (e.message ?: e.javaClass.simpleName), + e + ) + activeApiKey = switched.newKey + triedKeys += switched.newKey + true + } + } + ) { + openAIApi.createImage( + url = url, + authorization = "Bearer $activeApiKey", + extraHeaders = resolveCustomHeaders(provider.customHeaders, context.sessionId, activeApiKey), + request = request + ) + } AILogger.logResponse(context.sessionId, provider.id, response, seq) buildSuccess(response, n, outputPath, model) } catch (e: CancellationException) { @@ -232,7 +260,9 @@ class GenerateImageTool @Inject constructor( } catch (e: Exception) { val enriched = e.enrichWithHttpErrorBody() if (activeApiKey.isNotEmpty() && enriched.isKeySwitchFailure()) { - keyRotator.reportFailure(lastProviderId, context.sessionId, activeApiKey) + // 重试循环未走 onKeyFailure 的兜底(如自定义触发码把 5xx 也算作 Key 失败); + // 传 triedKeys,避免把本次已试过的 Key 又选回来。 + keyRotator.reportFailure(lastProviderId, context.sessionId, activeApiKey, triedKeys) } FileLogger.e(TAG, "generateImage 失败", enriched) AILogger.logError(context.sessionId, lastProviderId, enriched, seq) @@ -240,6 +270,16 @@ class GenerateImageTool @Inject constructor( } } + /** + * 生图这一路径的网络重试次数:在用户设置(设置 → 重试次数)之上再压一个生图专用上限。 + * + * 文本请求重试几乎无代价,生图则几十秒一张且按张计费:超时/连接中断时服务端可能已经生成并 + * 计费(Images API 无幂等键),重发就是在赌重复扣费。所以不改用户设置、只在生图侧封顶; + * 用户把重试设为 0 时照传 0。Key 切换不受这个上限约束(与文本路径一致,切完立即重发)。 + */ + private suspend fun imageGenMaxRetries(): Int = + generalSettingsRepository.maxNetworkRetries().coerceAtMost(MAX_IMAGE_GEN_RETRIES) + private suspend fun resolveImageGenProvider(): com.aicode.feature.settings.domain.model.AIProviderConfig { val providerId = imageGenModelSettingsRepository.getImageGenProviderId().trim() val model = imageGenModelSettingsRepository.getImageGenModel().trim() @@ -693,6 +733,15 @@ class GenerateImageTool @Inject constructor( const val MAX_IMAGES = 4 const val MAX_IMAGE_BYTES = 20L * 1024 * 1024 const val MAX_TOTAL_IMAGE_BYTES = 48L * 1024 * 1024 + + /** + * 生图网络失败的重试次数上限(不含首次请求)。 + * + * 文本路径直接用用户设置的次数(默认 6),生图不能照抄:一次生图几十秒且按张计费,而超时/ + * 连接中断时服务端可能已经生成并计费,重发就是在赌重复扣费。429、5xx 这类服务端明确未受理的 + * 失败值得退避重试,但最多 2 次(合计最多 3 次尝试);再多就只是把重复扣费的概率拉高。 + */ + const val MAX_IMAGE_GEN_RETRIES = 2 const val FORMAT_PNG = "png" const val FORMAT_JPEG = "jpeg" const val FORMAT_WEBP = "webp" diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/shizuku/ShizukuTool.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/shizuku/ShizukuTool.kt index a8c6c3af7..23e2a093c 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/tool/shizuku/ShizukuTool.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/shizuku/ShizukuTool.kt @@ -109,8 +109,7 @@ class ShizukuTool @Inject constructor( } private fun stateHint(state: ShizukuState): String = when (state) { - ShizukuState.NOT_INSTALLED -> "未安装 Shizuku,请先安装并启动 Shizuku 服务" - ShizukuState.NOT_RUNNING -> "Shizuku 服务未运行,请在 Shizuku 应用中启动服务" + ShizukuState.NOT_RUNNING -> "Shizuku 未就绪:未安装或服务未启动,请在「设置 → 软件权限 → Shizuku」处理" ShizukuState.PERMISSION_DENIED -> "本应用尚未获得 Shizuku 授权,请在设置中授予" ShizukuState.READY -> "" } diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt index fb560d3b5..9499d25ca 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt @@ -1915,12 +1915,14 @@ class AIAgentViewModel @Inject constructor( // cancel() 在 Dispatchers.Main.immediate 上可能立即恢复挂起协程 // (如 awaitApproval 的 CompletableDeferred.await),旧 job 的 finally → // flushPendingNotifications 在 cancel() 调用栈内同步执行并可能启动新 job。 - // 不预先清除待送通知——它们应由 finally 正常 flush 给新 job 处理。 + // 不预先清除待送通知——它们应由 finally 正常 flush 给新 job 处理; + // 也不在这里清:cancel() 的 finally 未必已跑完,此时清就是把待送通知凭空抹掉 + // (用户点的是「停止任务」,不是「丢掉我的消息 / 后台完成通知」), + // 而 finally 里的 flushPendingNotifications 无论如何都会把它们作为消息送出去。 job.cancel() // cancel 可能已同步执行完 finally(flush 启动了新 job 并注册到 sessionJobs), - // 此时不能再覆盖新 job 的状态;仅当无新 job 接管时才做清理。 + // 此时不能再覆盖新 job 的状态;仅当无新 job 接管时才做状态清理。 if (sessionJobs[sessionId]?.isActive != true) { - agentNotificationCenter.clear(sessionId) setAgentState(sessionId, AgentUIState.Idle) } _runningTools.value = _runningTools.value - sessionId diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageAttachmentComponents.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageAttachmentComponents.kt index e542c03d1..bfa36825d 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageAttachmentComponents.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageAttachmentComponents.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider import com.aicode.R import com.aicode.core.theme.Brand +import com.aicode.core.util.FileLogger import com.aicode.core.theme.Radius import com.aicode.core.theme.Spacing import com.aicode.core.ui.LocalImageViewer @@ -48,6 +49,8 @@ import compose.icons.feathericons.ChevronRight import compose.icons.feathericons.FileText import compose.icons.feathericons.Image import java.io.File +import java.nio.file.NoSuchFileException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -229,17 +232,26 @@ internal val LocalAttachmentOpener = staticCompositionLocalOf * 卡片记录的 [AgentAttachment.localPath] 不保证仍然有效:远程 SSH 模式下它是 `copyToLocal` 的临时副本 * (进程重启或系统清缓存后消失),外部本地目录工作区、自定义挂载源的文件又落在 FileProvider 声明的目录树之外。 * 因此失效时退回 [AgentAttachment.containerPath] 经 [fileAccess] 重取,重取到的路径仍无法授权时 - * 再复制进 cacheDir(已由 cache-path 覆盖)分享。文件确实不存在与最终无法分享分别提示,不再混用一句文案。 + * 再复制进 cacheDir(已由 cache-path 覆盖)分享。文件确实不存在、这次取不到(断线/未就绪)与最终 + * 无法分享分别提示,不混用一句文案:把可重试的连接问题说成「文件不存在」,用户会去删工作区而不是重连。 */ internal suspend fun openSentAttachment( context: Context, attachment: AgentAttachment, fileAccess: FileAccessProvider ) { - val local = withContext(Dispatchers.IO) { resolveLocalFile(attachment, fileAccess) } - if (local == null) { - Toast.makeText(context, context.getString(R.string.chat_open_file_missing), Toast.LENGTH_SHORT).show() - return + val localFile = when ( + val resolved = withContext(Dispatchers.IO) { resolveLocalFile(attachment, fileAccess) } + ) { + is LocalFileResolution.Found -> resolved.file + LocalFileResolution.Missing -> { + Toast.makeText(context, context.getString(R.string.chat_open_file_missing), Toast.LENGTH_SHORT).show() + return + } + LocalFileResolution.Unavailable -> { + Toast.makeText(context, context.getString(R.string.chat_open_file_unavailable), Toast.LENGTH_SHORT).show() + return + } } // APK 安装包:系统安装器要求「允许安装未知应用」授权,未授权时引导用户去设置页开启, // 否则点击只会弹出「没有权限安装」的拒绝提示。 @@ -260,7 +272,7 @@ internal suspend fun openSentAttachment( } } val shareable = withContext(Dispatchers.IO) { - if (isShareable(context, local)) local else copyToShareCache(context, local) + if (isShareable(context, localFile)) localFile else copyToShareCache(context, localFile) } val uri = shareable?.let { runCatching { FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", it) }.getOrNull() @@ -279,12 +291,42 @@ internal suspend fun openSentAttachment( } } +/** + * 附件落到宿主本地文件的结果。 + * + * 必须把「文件确实不在」与「这次取不到」分开:本地模式下 [FileAccessProvider.copyToLocal] 对不存在的路径 + * 只返回一个 isFile 为 false 的 [File](不抛异常),而远程模式对「远端没有这个文件」抛 [NoSuchFileException]、 + * 对断线/通道异常/工作区未落定抛 [java.io.IOException] 或 WorkspaceNotReadyException。前者是文件没了, + * 后者是文件可能还在、只是这次拿不到——提示语不能共用一句。 + */ +private sealed interface LocalFileResolution { + data class Found(val file: File) : LocalFileResolution + data object Missing : LocalFileResolution + data object Unavailable : LocalFileResolution +} + /** 卡片记录的宿主路径失效(远程临时副本被清、文件被删或移动)时,退回容器路径重取。 */ -private suspend fun resolveLocalFile(attachment: AgentAttachment, fileAccess: FileAccessProvider): File? { - attachment.localPath.takeIf { it.isNotBlank() }?.let(::File)?.takeIf { it.isFile }?.let { return it } +private suspend fun resolveLocalFile( + attachment: AgentAttachment, + fileAccess: FileAccessProvider +): LocalFileResolution { + attachment.localPath.takeIf { it.isNotBlank() }?.let(::File)?.takeIf { it.isFile } + ?.let { return LocalFileResolution.Found(it) } val containerPath = attachment.containerPath - if (containerPath.isBlank()) return null - return runCatching { fileAccess.copyToLocal(containerPath) }.getOrNull()?.takeIf { it.isFile } + if (containerPath.isBlank()) return LocalFileResolution.Missing + return try { + fileAccess.copyToLocal(containerPath).takeIf { it.isFile } + ?.let { LocalFileResolution.Found(it) } + ?: LocalFileResolution.Missing + } catch (e: CancellationException) { + // 协程取消不是「取不到」:继续走 catch (e: Exception) 会把它吞成一次提示,取消就再也传不上去。 + throw e + } catch (e: NoSuchFileException) { + LocalFileResolution.Missing + } catch (e: Exception) { + FileLogger.w(TAG, "取附件本地副本失败:$containerPath", e) + LocalFileResolution.Unavailable + } } /** 路径落在 FileProvider 声明的目录树内时可直接授权;不能就抛异常,故按能否取到 URI 判定。 */ @@ -305,6 +347,8 @@ private fun copyToShareCache(context: Context, source: File): File? = runCatchin private const val SHARE_CACHE_DIR = "sent_files" +private const val TAG = "MessageAttachment" + private fun isApk(attachment: AgentAttachment): Boolean { if (attachment.mimeType.equals("application/vnd.android.package-archive", ignoreCase = true)) return true val name = attachment.fileName.lowercase() diff --git a/app/src/main/java/com/aicode/feature/credentials/data/repository/FileCredentialRepository.kt b/app/src/main/java/com/aicode/feature/credentials/data/repository/FileCredentialRepository.kt index 6c5171c33..04f0e742a 100644 --- a/app/src/main/java/com/aicode/feature/credentials/data/repository/FileCredentialRepository.kt +++ b/app/src/main/java/com/aicode/feature/credentials/data/repository/FileCredentialRepository.kt @@ -119,9 +119,19 @@ class FileCredentialRepository @Inject constructor( private fun encode(plain: String): String = Base64.encodeToString(plain.toByteArray(Charsets.UTF_8), Base64.NO_WRAP).reversed() - /** 解码编码串;不是本格式(如旧版明文)时返回 null。 */ + /** + * 解码编码串;不是本格式(如旧版明文)时返回 null。 + * + * android.util.Base64 的 DEFAULT 解码是「宽容」的:`:`、`/`、`@` 等非法字符一律按 SKIP 忽略、 + * 不抛异常,明文也能被「解码」成乱码。因此不能以「未抛异常」判定格式,改用往返校验:只有 + * `encode(decode(raw)) == raw` 才认定为编码格式,否则按明文处理(旧版明文需回退解析,否则凭据全部读空)。 + */ private fun tryDecode(text: String): String? = runCatching { - String(Base64.decode(text.reversed(), Base64.DEFAULT), Charsets.UTF_8) + val raw = text.trim() + if (raw.isEmpty()) return@runCatching null + val decoded = String(Base64.decode(raw.reversed(), Base64.DEFAULT), Charsets.UTF_8) + if (encode(decoded) != raw) return@runCatching null + decoded }.getOrNull() /** 把旧版明文凭据文件迁移为编码格式(幂等)。 */ diff --git a/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorScreen.kt b/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorScreen.kt index 13e6a6f78..2476c1e68 100644 --- a/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorScreen.kt +++ b/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorScreen.kt @@ -112,6 +112,7 @@ fun CodeEditorScreen( val baselineText = remember { mutableStateOf("") } var pendingSaveText by remember { mutableStateOf("") } var pendingExit by remember { mutableStateOf(false) } + var showSaveConflict by remember { mutableStateOf(false) } var showUnsavedDialog by remember { mutableStateOf(false) } var showSettings by remember { mutableStateOf(false) } var previewMode by remember { mutableStateOf(false) } @@ -130,6 +131,7 @@ fun CodeEditorScreen( canRedo = false dirty = false pendingExit = false + showSaveConflict = false cursorLine = 1 cursorColumn = 1 previewMode = false @@ -158,6 +160,8 @@ fun CodeEditorScreen( Toast.LENGTH_LONG ).show() } + // 文件被外部改过:弹二次确认(不硬拦,确认后强制写盘) + is SaveResult.Conflict -> showSaveConflict = true } } } @@ -330,6 +334,27 @@ fun CodeEditorScreen( } } + if (showSaveConflict) { + AlertDialog( + onDismissRequest = { showSaveConflict = false }, + title = { Text(stringResource(R.string.editor_save_conflict_title)) }, + text = { Text(stringResource(R.string.editor_save_conflict_message)) }, + confirmButton = { + TextButton(onClick = { + showSaveConflict = false + // 强制写盘:pendingExit 保持不变,保存成功后照旧走退出流程 + viewModel.save(pendingSaveText, force = true) + }) { Text(stringResource(R.string.editor_save_conflict_overwrite)) } + }, + dismissButton = { + TextButton(onClick = { + showSaveConflict = false + pendingExit = false + }) { Text(stringResource(R.string.common_cancel)) } + } + ) + } + if (showUnsavedDialog) { AlertDialog( onDismissRequest = { showUnsavedDialog = false }, diff --git a/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorViewModel.kt b/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorViewModel.kt index b02eaa78e..f195f5337 100644 --- a/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorViewModel.kt +++ b/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorViewModel.kt @@ -35,6 +35,12 @@ sealed interface EditorUiState { sealed interface SaveResult { data object Success : SaveResult data class Error(val detail: String?) : SaveResult + + /** + * 文件在打开之后被别的程序/会话改过(mtime 变新),本次没有写盘。 + * 由 UI 二次确认后带 force = true 重来——只提示,不硬拦。 + */ + data object Conflict : SaveResult } @HiltViewModel @@ -59,16 +65,23 @@ class CodeEditorViewModel @Inject constructor( private var loadedPath: String? = null + /** 打开时记录的文件修改时间(毫秒;0 表示取不到)。供保存前判断文件是否被外部改过。 */ + private var loadedModifiedAt: Long = 0L + /** 重复调用同一路径不会重复读盘,供 Compose 重组时安全调用。 */ fun load(path: String) { if (loadedPath == path) return loadedPath = path + // 换文件先清掉基准:否则会拿上一个文件的时间戳去判新文件的冲突 + loadedModifiedAt = 0L _uiState.value = EditorUiState.Loading viewModelScope.launch(Dispatchers.IO) { _uiState.value = runCatching { if (!fileAccess.exists(path) || !fileAccess.isFile(path)) { return@runCatching EditorUiState.Error(null) } + // 保存冲突检测的基准:取不到时间戳(0)时后面不做冲突判定,宁可漏报也不误报 + loadedModifiedAt = runCatching { fileAccess.lastModified(path) }.getOrDefault(0L) val size = fileAccess.fileSize(path) if (size > MAX_EDITABLE_BYTES) { return@runCatching EditorUiState.TooLarge(size) @@ -97,15 +110,30 @@ class CodeEditorViewModel @Inject constructor( } } - /** 把编辑器当前内容写回文件。写入在 IO 线程进行,结果通过 [saveEvents] 通知,期间 [saving] 置位。 */ - fun save(content: String) { + /** + * 把编辑器当前内容写回文件。写入在 IO 线程进行,结果通过 [saveEvents] 通知,期间 [saving] 置位。 + * + * [force] = false 时先比对 mtime:文件在打开之后被别的程序/会话改过就先发 [SaveResult.Conflict] + * 并且**不写盘**,由 UI 提示用户二次确认(确认后带 force = true 重来)。只提示不硬拦: + * SFTP v3 的 mtime 只有秒级精度、远端 FS 时间戳也未必可靠,硬拦会误报卡人。 + */ + fun save(content: String, force: Boolean = false) { val path = loadedPath ?: return viewModelScope.launch(Dispatchers.IO) { _saving.value = true try { + if (!force && hasExternalChange()) { + FileLogger.w(TAG, "保存前检测到文件已被外部修改: $path") + _saveEvents.send(SaveResult.Conflict) + return@launch + } val result = runCatching { fileAccess.writeFile(path, content) } .fold( - onSuccess = { SaveResult.Success }, + onSuccess = { + // 自己写完就刷新基准,否则下一次保存必然把自己的写入当成外部改动 + loadedModifiedAt = runCatching { fileAccess.lastModified(path) }.getOrDefault(0L) + SaveResult.Success + }, onFailure = { e -> FileLogger.w(TAG, "保存文件失败: $path", e) SaveResult.Error(e.message) @@ -118,6 +146,14 @@ class CodeEditorViewModel @Inject constructor( } } + /** 文件是否在打开之后被外部改动过(mtime 变新)。基准或当前值取不到(0)时不判定冲突。 */ + private fun hasExternalChange(): Boolean { + val path = loadedPath ?: return false + if (loadedModifiedAt <= 0L) return false + val current = runCatching { fileAccess.lastModified(path) }.getOrDefault(0L) + return current > loadedModifiedAt + } + private companion object { const val TAG = "CodeEditorViewModel" diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt index 9e62b8292..d0c12cd54 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt @@ -75,6 +75,7 @@ import com.aicode.feature.settings.data.repository.ToolSafetySettingsRepository import com.aicode.feature.settings.data.repository.BackgroundSettingsRepository import com.aicode.feature.settings.data.repository.ImageGenModelSettingsRepository import com.aicode.feature.settings.data.repository.VisionModelSettingsRepository +import com.aicode.feature.workspace.data.repository.WorkspaceRepository import com.aicode.feature.workspace.domain.model.RemoteConnection import com.aicode.feature.workspace.domain.repository.RemoteRepository import com.aicode.feature.settings.domain.model.AIProviderConfig @@ -330,6 +331,7 @@ class SettingsViewModel @Inject constructor( private val containerOsDetector: ContainerOsDetector, private val executionModeRepository: ExecutionModeRepository, private val executionModeHolder: ExecutionModeHolder, + private val workspaceRepository: WorkspaceRepository, private val remoteSshConnection: RemoteSshConnection, private val remoteRepository: RemoteRepository, private val llmCallRecordDao: LlmCallRecordDao, @@ -955,6 +957,17 @@ class SettingsViewModel @Inject constructor( } } + // 项目级技能/子代理的目录由当前工作区解析(远程模式下是服务器上被选中的工作区)。 + // 建连与工作区加载是两条异步链:连接就绪时工作区可能还没落定,此时 currentPath() 回退到 + // 工作区根目录,扫出来的是空列表;工作区随后落定(或用户切换工作区)不再触发任何重扫, + // 列表就停在旧内容/空列表上。故工作区一变就重扫。 + launch { + workspaceRepository.current.collectLatest { + refreshSkills() + refreshSubAgents() + } + } + launch { permissionRulesRepository.globalRulesFlow.collectLatest { _globalRules.value = it diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/ShizukuViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/ShizukuViewModel.kt index c1456796c..ca39e6982 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/ShizukuViewModel.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/ShizukuViewModel.kt @@ -1,10 +1,17 @@ package com.aicode.feature.settings.presentation import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.aicode.feature.agent.domain.shizuku.ShizukuManager +import com.aicode.feature.agent.domain.shizuku.ShizukuPeerInfo import com.aicode.feature.agent.domain.shizuku.ShizukuState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject /** Shizuku 执行后端的设置页状态与操作入口。 */ @@ -15,6 +22,17 @@ class ShizukuViewModel @Inject constructor( val state: StateFlow = shizukuManager.state + /** + * 当前连到的服务端身份(adb shell / root),供设置页展示「当前连的是谁」。 + * + * 身份只能从服务端读,未就绪时 [ShizukuManager.peerInfo] 直接返回 null,所以这条流只跟着 + * [state] 变化重读——就绪且身份不变时不重复问 binder。读的是 binder 调用,放 IO 上做。 + */ + val peerInfo: StateFlow = shizukuManager.state + .map { shizukuManager.peerInfo() } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + /** 重新探测状态(如从 Shizuku 应用返回后)。 */ fun refresh() = shizukuManager.refreshState() diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/AppPermissionsSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/AppPermissionsSection.kt index a169227ab..f0812d648 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/AppPermissionsSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/AppPermissionsSection.kt @@ -33,6 +33,7 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LifecycleResumeEffect import com.aicode.R import com.aicode.core.theme.Spacing +import com.aicode.feature.agent.domain.shizuku.ShizukuPeerInfo import com.aicode.feature.agent.domain.shizuku.ShizukuState import compose.icons.FeatherIcons import compose.icons.feathericons.Bell @@ -42,6 +43,7 @@ import compose.icons.feathericons.Power import compose.icons.feathericons.RefreshCw import compose.icons.feathericons.Sun import compose.icons.feathericons.Terminal +import compose.icons.feathericons.User import compose.icons.feathericons.Zap /** @@ -49,12 +51,13 @@ import compose.icons.feathericons.Zap * - 安装未知应用:展示授权状态,未授权点击跳转系统设置开启。 * - 访问存储空间:展示授权状态,未授权点击申请运行时权限;已被永久拒绝时同样引导去系统设置。 * - 忽略电池优化 / 自启动管理:跳转系统设置。 - * - Shizuku:展示 adb shell 授权状态,未就绪时点击安装/启动/申请授权。 + * - Shizuku:展示 adb shell 授权状态与当前连到的服务端身份,未就绪时点击安装/启动/申请授权。 * 页面恢复(含从系统设置页或 Shizuku 应用返回)时刷新各权限状态。 */ @Composable internal fun AppPermissionsSection( shizukuState: ShizukuState, + shizukuPeer: ShizukuPeerInfo?, onRequestShizukuPermission: () -> Unit, onOpenShizuku: () -> Unit, onRefreshShizuku: () -> Unit @@ -160,7 +163,7 @@ internal fun AppPermissionsSection( ) SettingsDivider() val shizukuAction: (() -> Unit)? = when (shizukuState) { - ShizukuState.NOT_INSTALLED, ShizukuState.NOT_RUNNING -> onOpenShizuku + ShizukuState.NOT_RUNNING -> onOpenShizuku ShizukuState.PERMISSION_DENIED -> onRequestShizukuPermission ShizukuState.READY -> null } @@ -177,6 +180,16 @@ internal fun AppPermissionsSection( ) } ) + // 就绪后多给一行「当前连的是谁」:adb shell 与 root 能做的事不一样 + //(root 才读得到其它应用的私有目录),只写「已就绪」看不出这点。 + shizukuPeer?.let { peer -> + SettingsDivider() + SettingsRow( + icon = FeatherIcons.User, + title = stringResource(R.string.settings_shizuku_peer_title), + subtitle = shizukuPeerLabel(peer) + ) + } } } @@ -296,7 +309,6 @@ internal fun BackgroundRunSection( /** Shizuku 状态对应的右侧状态文字。 */ @StringRes private fun ShizukuState.statusRes(): Int = when (this) { - ShizukuState.NOT_INSTALLED -> R.string.settings_shizuku_status_not_installed ShizukuState.NOT_RUNNING -> R.string.settings_shizuku_status_not_running ShizukuState.PERMISSION_DENIED -> R.string.settings_shizuku_status_denied ShizukuState.READY -> R.string.settings_shizuku_status_ready @@ -305,12 +317,19 @@ private fun ShizukuState.statusRes(): Int = when (this) { /** Shizuku 状态对应的副标题(点击提示)。 */ @StringRes private fun ShizukuState.hintRes(): Int = when (this) { - ShizukuState.NOT_INSTALLED -> R.string.settings_shizuku_hint_not_installed ShizukuState.NOT_RUNNING -> R.string.settings_shizuku_hint_not_running ShizukuState.PERMISSION_DENIED -> R.string.settings_shizuku_hint_denied ShizukuState.READY -> R.string.settings_shizuku_hint_ready } +/** 服务端身份的展示文字:adb shell / root 各自一档,其余按 uid 如实写。 */ +@Composable +private fun shizukuPeerLabel(peer: ShizukuPeerInfo): String = when { + peer.isAdb -> stringResource(R.string.settings_shizuku_peer_adb) + peer.isRoot -> stringResource(R.string.settings_shizuku_peer_root) + else -> stringResource(R.string.settings_shizuku_peer_other, peer.uid) +} + /** 权限无法通过系统弹框授予时,提醒用户去系统设置手动开启。 */ @Composable private fun PermissionDeniedDialog( diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt index eab2fabe9..50865fa3c 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt @@ -1046,8 +1046,10 @@ fun SettingsScreen( val shizukuViewModel: ShizukuViewModel = androidx.hilt.navigation.compose.hiltViewModel() val shizukuState by shizukuViewModel.state.collectAsStateWithLifecycle() + val shizukuPeer by shizukuViewModel.peerInfo.collectAsStateWithLifecycle() AppPermissionsSection( shizukuState = shizukuState, + shizukuPeer = shizukuPeer, onRequestShizukuPermission = { shizukuViewModel.requestPermission() }, onOpenShizuku = { shizukuViewModel.openShizukuApp() }, onRefreshShizuku = { shizukuViewModel.refresh() } diff --git a/app/src/main/java/com/aicode/feature/terminal/domain/RemoteTerminalSessionManager.kt b/app/src/main/java/com/aicode/feature/terminal/domain/RemoteTerminalSessionManager.kt index ce4f3afa2..26be6dcba 100644 --- a/app/src/main/java/com/aicode/feature/terminal/domain/RemoteTerminalSessionManager.kt +++ b/app/src/main/java/com/aicode/feature/terminal/domain/RemoteTerminalSessionManager.kt @@ -51,6 +51,12 @@ class RemoteTerminalSessionManager @Inject constructor( private val idCounter = AtomicInteger(0) + /** + * 标签 id → 它的 SSH shell backend。用于在会话结束时判断是「正常结束」还是「断线结束」: + * [SshShellBackend.closedByDisconnect] 只能从 backend 上读到。 + */ + private val backends = mutableMapOf() + val activeTab: TerminalTab? get() = _tabs.value.firstOrNull { it.id == _activeTabId.value } fun tab(id: String): TerminalTab? = _tabs.value.firstOrNull { it.id == id } @@ -107,7 +113,8 @@ class RemoteTerminalSessionManager @Inject constructor( val shell = withContext(Dispatchers.IO) { connection.startShellSession().also { it.allocateDefaultPTY() }.startShell() } - val backend = SshShellBackend(shell) + val backend = SshShellBackend(shell) { connection.isConnected() } + backends[id] = backend val termSession = TerminalSession(TRANSCRIPT_ROWS, AppRemoteSessionClient(), backend) termSession.updateSize(DEFAULT_COLUMNS, DEFAULT_ROWS) // shell 登录后默认在 home,先 cd 到当前工作区,与命令执行链路(RemoteSshEngine.buildCdCommand)保持一致: @@ -181,6 +188,7 @@ class RemoteTerminalSessionManager @Inject constructor( val tab = tab(id) ?: return false runCatching { tab.session.finishIfRunning() } tab.view = null + backends.remove(id) val remaining = _tabs.value.filterNot { it.id == id } _tabs.value = remaining if (_activeTabId.value == id) { @@ -239,7 +247,14 @@ class RemoteTerminalSessionManager @Inject constructor( override fun onTitleChanged(changedSession: TerminalSession) {} override fun onSessionFinished(finishedSession: TerminalSession) { _tabs.value.firstOrNull { it.session === finishedSession }?.let { target -> + // 断线导致的结束:远程 shell 没有有意义的退出码,只看 Termux 的 + // 「[Process completed]」分不出是用户敲了 exit 还是网断了,得看 backend 的标记。 + val dropped = backends[target.id]?.closedByDisconnect == true target.runState = RunState.Finished(0) + if (dropped) { + target.droppedByDisconnect = true + FileLogger.w(TAG, "远程 shell 因连接断开而结束: ${target.id}") + } bumpRevision() if (target.notifyOnExit) { _tabFinishedEvents.tryEmit( diff --git a/app/src/main/java/com/aicode/feature/terminal/domain/SshShellBackend.kt b/app/src/main/java/com/aicode/feature/terminal/domain/SshShellBackend.kt index 74fb08ce8..de859fd84 100644 --- a/app/src/main/java/com/aicode/feature/terminal/domain/SshShellBackend.kt +++ b/app/src/main/java/com/aicode/feature/terminal/domain/SshShellBackend.kt @@ -20,15 +20,31 @@ private const val TAG = "SshShellBackend" * here. [waitForExit] blocks on [Session.Shell.join]; remote shells have no meaningful * exit status, so 0 is returned (the emulator appends its own "[Process completed]" notice * via [com.termux.terminal.TerminalSession] once the reader thread hits EOF). + * + * 因为返回值永远是 0,「断网导致的结束」与「用户敲 exit」在 UI 上长得一模一样;[closedByDisconnect] + * 把前者单独标出来,供 [RemoteTerminalSessionManager] 打「已断开」。返回值语义([SessionBackend] + * 的约定,本地终端共用)不动。 */ class SshShellBackend( - private val shell: Session.Shell + private val shell: Session.Shell, + /** 连接是否仍然活着;由 [RemoteTerminalSessionManager] 接 RemoteSshConnection.isConnected。 */ + private val isConnectionAlive: () -> Boolean = { true } ) : SessionBackend { // changeWindowDimensions 走网络 I/O,resize 会被 TerminalView 在主线程触发,需切到后台线程 private val resizeExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "SshShellResize") } @Volatile private var closed = false + /** + * shell 是在「连接已经不在了」的情况下结束的(断网/服务器重启),而不是用户敲了 exit。 + * + * 在 [waitForExit] 里置位:Termux 的 TermSessionWaiter 线程先拿到它的返回值、再经主线程 + * handler 回调 onSessionFinished,所以标记一定早于回调被读到(@Volatile 保证可见性)。 + */ + @Volatile + var closedByDisconnect: Boolean = false + private set + override fun getInputStream(): InputStream = shell.inputStream override fun getOutputStream(): OutputStream = shell.outputStream @@ -42,13 +58,13 @@ class SshShellBackend( } override fun waitForExit(): Int { - return runCatching { - shell.join() - 0 - }.getOrElse { - FileLogger.w(TAG, "shell.join 异常", it) - 0 + val joined = runCatching { shell.join() } + // join 正常返回 = channel 正常结束;抛异常,或结束时连接已经不在了,都算异常结束(断线) + if (joined.isFailure || !isConnectionAlive()) { + joined.exceptionOrNull()?.let { FileLogger.w(TAG, "shell.join 异常", it) } + closedByDisconnect = true } + return 0 } override fun close() { diff --git a/app/src/main/java/com/aicode/feature/terminal/domain/TerminalTab.kt b/app/src/main/java/com/aicode/feature/terminal/domain/TerminalTab.kt index 8bc9b7ed6..26ebec5c3 100644 --- a/app/src/main/java/com/aicode/feature/terminal/domain/TerminalTab.kt +++ b/app/src/main/java/com/aicode/feature/terminal/domain/TerminalTab.kt @@ -68,6 +68,13 @@ class TerminalTab( */ @Volatile var finishedNotified: Boolean = false + + /** + * 远程会话是否是因连接断开而结束(见 [SshShellBackend.closedByDisconnect]);本地终端恒 false。 + * 断线结束时 Termux 只会打一句「[Process completed]」,不标出来会被当成正常退出。 + */ + var droppedByDisconnect: Boolean = false + internal set } /** diff --git a/app/src/main/java/com/aicode/feature/terminal/presentation/component/TerminalScreen.kt b/app/src/main/java/com/aicode/feature/terminal/presentation/component/TerminalScreen.kt index d942a0303..d7620d1b3 100644 --- a/app/src/main/java/com/aicode/feature/terminal/presentation/component/TerminalScreen.kt +++ b/app/src/main/java/com/aicode/feature/terminal/presentation/component/TerminalScreen.kt @@ -161,8 +161,14 @@ fun TerminalScreen( onNew = { viewModel.newTab() } ) + val activeTab = tabs.firstOrNull { it.id == activeTabId } + // 因连接断开而结束的会话:不单独标出来,用户看到的只是 Termux 那句「[Process completed]」 + if (activeTab?.droppedByDisconnect == true) { + ConnectionDroppedBanner() + } + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - val active = tabs.firstOrNull { it.id == activeTabId } + val active = activeTab if (active == null) { StatusView( loading = creatingTab, @@ -622,3 +628,31 @@ private fun KeyChip( /** 单字符按键给固定宽度,多字符按键自适应。 */ private fun Modifier.widthChip(label: String): Modifier = if (label.length <= 1) this.width(36.dp) else this + +/** + * 连接断开导致会话结束的横幅。 + * + * 远程 shell 的退出码无意义(SshShellBackend.waitForExit 恒返 0),断网后 Termux 只会打一句 + * 「[Process completed]」——与用户敲 exit 长得一样,不提示会让人以为终端好好的。 + */ +@Composable +private fun ConnectionDroppedBanner() { + Surface(color = MaterialTheme.colorScheme.errorContainer) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.md, vertical = Spacing.sm) + ) { + Text( + text = stringResource(R.string.terminal_connection_dropped), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = stringResource(R.string.terminal_connection_dropped_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} diff --git a/app/src/main/java/com/aicode/feature/workspace/domain/FileAccessProvider.kt b/app/src/main/java/com/aicode/feature/workspace/domain/FileAccessProvider.kt index f194d9890..2ee2aab32 100644 --- a/app/src/main/java/com/aicode/feature/workspace/domain/FileAccessProvider.kt +++ b/app/src/main/java/com/aicode/feature/workspace/domain/FileAccessProvider.kt @@ -110,6 +110,11 @@ interface FileAccessProvider { * 本地模式直接返回映射后的宿主 [File](不复制); * 远程模式 SFTP 下载到缓存目录,返回本地临时文件。 * 供 [ViewImageTool](需本地路径喂 BitmapFactory)等使用。 + * + * 异常契约:本地实现不检查存在性——路径不存在时返回的 [File] 用 `isFile` 判定为 false,不抛异常; + * 远程实现对「远端确实没有这个文件」抛 [NoSuchFileException],对断线、通道异常、SSH 未连接等 + * 其它失败抛 [java.io.IOException]。两类失败语义不同(前者是文件没了、后者是这次拿不到), + * 调用方要分别处理——只接 [File] 而把异常一律当成文件不存在,就会把连接问题说成文件丢失。 */ fun copyToLocal(path: String): File diff --git a/app/src/main/java/com/aicode/feature/workspace/domain/RemoteSftpFileAccess.kt b/app/src/main/java/com/aicode/feature/workspace/domain/RemoteSftpFileAccess.kt index f3add557c..5d49528df 100644 --- a/app/src/main/java/com/aicode/feature/workspace/domain/RemoteSftpFileAccess.kt +++ b/app/src/main/java/com/aicode/feature/workspace/domain/RemoteSftpFileAccess.kt @@ -249,9 +249,11 @@ class RemoteSftpFileAccess @Inject constructor( } catch (e: Exception) { // 用完即删:临时文件不依赖 deleteOnExit(只在进程退出清),失败路径也回收,避免长会话累积。 runCatching { tempFile.delete() } + // 只有真的是「不存在」才报不存在:断线/通道异常一律报连接类原因, + // 否则 AI 读图时看到的是「文件不存在」,而文件其实就在服务器上 if (e is NoSuchFileException) throw e FileLogger.e(TAG, "copyToLocal 失败: $remote", e) - throw NoSuchFileException(File(remote)) + throw IOException(friendlySshError(e), e) } } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index c720d1773..e182d3424 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -60,14 +60,16 @@ Tool Permissions Connections & Sync Shizuku - Not installed - Service not running + Not ready Not authorized Ready - Tap to download and install Shizuku - Tap to open Shizuku and start the service + Tap to open Shizuku and start the service; opens the download page if it is not installed Tap to request Shizuku permission Authorized — AI can run commands via adb shell + Connected as + adb shell (uid 2000) + root (uid 0) + uid %1$d Backup & Restore About Language @@ -370,6 +372,7 @@ Extra high Max File not found or has been moved + Cannot read this file: the connection is lost or the workspace is not ready yet, please try again later This file\'s location cannot be shared with other apps No app available to open this file Installing apps requires permission. Allow installs from this source in the settings that just opened @@ -552,6 +555,9 @@ %1$d sp File too large (%1$d MB) to open Failed to open file + File changed elsewhere + This file was modified by another program or session after it was opened. Saving will overwrite those changes. Save anyway? + Save anyway Binary file, opening not supported @@ -1350,6 +1356,8 @@ Cannot start terminal:\n%1$s Retry No open terminal tabs + Connection lost, this terminal has ended + This was not a normal exit: open a new tab to retry, or reopen after automatic reconnect Close Tab Cleaning up old container data…\n%1$s items removed Extracting environment…\n%1$s files processed diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f3fb336a..32f955400 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -60,14 +60,16 @@ 工具授权 连接与同步 Shizuku - 未安装 - 服务未运行 + 未就绪 未授权 已就绪 - 点击前往下载安装 Shizuku - 点击打开 Shizuku 并启动服务 + 点击打开 Shizuku 并启动服务;未安装会跳转下载页 点击申请 Shizuku 授权 已获授权,AI 可通过 adb shell 执行命令 + 当前连接 + adb shell(uid 2000) + root(uid 0) + uid %1$d 备份与还原 关于 语言 @@ -370,6 +372,7 @@ 极高 最高 文件不存在或已被移动 + 无法读取文件:连接已断开或工作区未就绪,请稍后重试 文件所在位置无法分享给其它应用 没有可打开该文件的应用 安装应用需要授权,请在跳转的设置中允许来自此来源的应用 @@ -547,6 +550,9 @@ %1$d sp 文件过大(%1$d MB),暂不支持打开 文件打开失败 + 文件已被其他改动 + 该文件在打开后被其它程序或会话修改过,保存会覆盖那些改动。仍要保存吗? + 仍要保存 二进制文件,暂不支持打开 重命名会话 会话名 @@ -1349,6 +1355,8 @@ 无法启动终端:\n%1$s 重试 没有打开的终端标签 + 连接已断开,此终端已结束 + 这不是正常退出:可新建标签重试,或等待自动重连后重新打开 关闭标签 正在清理旧容器数据…\n已清理 %1$s 项 正在解压环境…\n已处理 %1$s 个文件 diff --git a/docs-site/docs/guide/app-permissions.md b/docs-site/docs/guide/app-permissions.md index 97ddcfc60..ba15ee8e8 100644 --- a/docs-site/docs/guide/app-permissions.md +++ b/docs-site/docs/guide/app-permissions.md @@ -28,8 +28,8 @@ Shizuku 让本应用以 adb shell(uid 2000)身份执行系统命令,是 AI 的 `Shizuku` 工具的前提。这一行显示当前状态: -- **未安装 / 服务未运行**:点击打开 Shizuku 应用,按其中的指引安装或启动服务。 +- **未就绪**:点击打开 Shizuku 应用启动服务;设备上没装管理器时,点击会跳到官方下载页。 - **未授权**:点击申请授权,在 Shizuku 弹出的授权框里选「允许」。 -- **已就绪**:授权完成,AI 即可调用 `Shizuku` 工具。 +- **已就绪**:授权完成,AI 即可调用 `Shizuku` 工具;下面多一行「当前连接」,写明当前是以 adb shell(uid 2000)还是 root(uid 0)身份连上的。 从 Shizuku 应用返回本页时状态会自动刷新。用法与安全注意事项见 [Shizuku 工具](/guide/shizuku)。 diff --git a/docs-site/docs/guide/shizuku.md b/docs-site/docs/guide/shizuku.md index c999e8a2f..a9797555a 100644 --- a/docs-site/docs/guide/shizuku.md +++ b/docs-site/docs/guide/shizuku.md @@ -15,10 +15,11 @@ Shizuku 是 shell 身份,**无法读取其他应用的私有数据目录**( 打开「设置 → 软件权限 → Shizuku」,页面显示当前状态: -- **未安装**:点击前往下载安装。 -- **服务未运行**:点击打开 Shizuku 并启动服务。 +- **未就绪**:点击打开 Shizuku 并启动服务;设备上没装管理器时,点击会直接跳到官方下载页。 - **未授权**:点击申请授权,Shizuku 弹出授权框,选择「允许」。 -- **已就绪**:授权完成,AI 即可使用 `Shizuku` 工具。 +- **已就绪**:授权完成,AI 即可使用 `Shizuku` 工具;此时下面多一行「当前连接」,写明当前是以 adb shell(uid 2000)还是 root(uid 0)身份连上的。 + +「未安装 / 服务没启动」在状态上是一回事(都只看 binder 通不通):管理器包名不在 Shizuku 的官方契约内(Sui 是 Magisk 模块、没有独立管理器包,Stellar 等兼容层用各自的包名),按包名判「装没装」必然误判。 从 Shizuku 应用返回 AiCode 时,状态会自动刷新。