Skip to content
Closed
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 @@ -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,

/** 服务运行中,但本应用尚未获得授权。 */
Expand All @@ -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)身份执行命令。
*
Expand All @@ -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 混淆后不稳定,故显式固定。 */
Expand All @@ -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<ShizukuState> = _state.asStateFlow()

private val bindMutex = Mutex()
Expand Down Expand Up @@ -157,32 +190,55 @@ 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
runCatching { Shizuku.requestPermission(PERMISSION_REQUEST_CODE) }
.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)
Expand All @@ -196,6 +252,42 @@ class ShizukuManager @Inject constructor(
}.onFailure { FileLogger.w(TAG, "打开 Shizuku 失败: ${it.message}") }
}

/**
* 找一个可启动的管理器:先按已知包名(官方、Stellar),再按 provider authority 命名约定扫描。
*
* 扫描只认「包自身声明了 authority 等于 `<包名>.shizuku` 或 `<包名>.stellar`」这一条,
* 且排除本应用自己(自身就声明 `<applicationId>.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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,21 @@ 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
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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -176,10 +180,13 @@ class GenerateImageTool @Inject constructor(
var seq = 0
var lastProviderId = ""
var activeApiKey = ""
// 本次已用过的 Key(含首次):重试时不再选它。声明在 try 外,失败出口还要用它上报。
val triedKeys = mutableSetOf<String>()
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 生图模型时切独立通道,
Expand Down Expand Up @@ -219,27 +226,60 @@ 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) {
throw e
} 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)
ToolResult.Error(enriched.message ?: "生图调用失败", "IMAGE_GEN_FAILED")
}
}

/**
* 生图这一路径的网络重试次数:在用户设置(设置 → 重试次数)之上再压一个生图专用上限。
*
* 文本请求重试几乎无代价,生图则几十秒一张且按张计费:超时/连接中断时服务端可能已经生成并
* 计费(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()
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> ""
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading