diff --git a/app/src/main/java/com/ai/assistance/operit/data/skill/SkillRepository.kt b/app/src/main/java/com/ai/assistance/operit/data/skill/SkillRepository.kt index 5663f1c662..072f9584bd 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/skill/SkillRepository.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/skill/SkillRepository.kt @@ -30,6 +30,8 @@ class SkillRepository private constructor(private val context: Context) { private const val CONNECT_TIMEOUT = 15_000 private const val READ_TIMEOUT = 30_000 private const val BUFFER_SIZE = 64 * 1024 + private const val OPERIT_META_DIR = ".operit" + private const val GITHUB_ZIP_KEY_FILE = "github_zip_key" private val SKILL_ID_PATTERN = Regex("^[A-Za-z0-9._-]+$") fun getInstance(context: Context): SkillRepository { @@ -69,7 +71,15 @@ class SkillRepository private constructor(private val context: Context) { fun readSkillContent(skillName: String): String? = skillManager.readSkillContent(skillName) - fun deleteSkill(skillName: String): Boolean = skillManager.deleteSkill(skillName) + suspend fun deleteSkill(skillName: String): Boolean { + val skill = skillManager.getAvailableSkills()[skillName] ?: return false + val zipKey = readGithubZipKey(skill.directory) + val ok = skillManager.deleteSkill(skillName) + if (ok && !zipKey.isNullOrBlank()) { + SkillRepoZipPoolManager.invalidate(zipKey) + } + return ok + } suspend fun importSkillFromZip(zipFile: File): String { return withContext(Dispatchers.IO) { @@ -99,10 +109,19 @@ class SkillRepository private constructor(private val context: Context) { ?: getGithubDefaultBranch(owner, repoName)?.also { defaultBranchCache[repoKey] = it } ?: return@withContext SkillRepoImportResult(context.getString(R.string.skill_cannot_determine_default_branch, "$owner/$repoName"), null) - val encodedRef = encodePathSegment(ref) - val zipUrl = "https://codeload.github.com/$owner/$repoName/zip/$encodedRef" - val repoRefKey = "$owner/$repoName@$ref" - val pooledZip = SkillRepoZipPoolManager.getOrDownloadZip(repoRefKey) { outFile -> + val commitSha = resolveGithubCommitSha(owner, repoName, ref) + val zipIdentity = commitSha ?: ref + val zipUrl = "https://codeload.github.com/$owner/$repoName/zip/${encodePathSegment(zipIdentity)}" + val repoRefKey = SkillRepoZipPoolManager.poolKey(owner, repoName, zipIdentity) + if (!commitSha.isNullOrBlank() && commitSha != ref) { + SkillRepoZipPoolManager.invalidate( + SkillRepoZipPoolManager.poolKey(owner, repoName, ref) + ) + } + val pooledZip = SkillRepoZipPoolManager.getOrDownloadZip( + key = repoRefKey, + forceRefresh = commitSha.isNullOrBlank() + ) { outFile -> downloadFromUrl(zipUrl, outFile) } @@ -127,6 +146,9 @@ class SkillRepository private constructor(private val context: Context) { } val result = skillManager.importSkillFromZipDetailed(zipFile, target.subDir) + if (result.installedDir != null) { + runCatching { writeGithubZipKey(result.installedDir, repoRefKey) } + } if (pooledZip == null) { runCatching { fallbackTempFile.delete() } @@ -411,6 +433,60 @@ class SkillRepository private constructor(private val context: Context) { return true } + private fun isFullCommitSha(value: String): Boolean { + return value.length == 40 && value.all { ch -> + ch.isDigit() || ch.lowercaseChar() in 'a'..'f' + } + } + + private fun resolveGithubCommitSha(owner: String, repoName: String, ref: String): String? { + if (isFullCommitSha(ref)) { + return ref.lowercase() + } + return getGithubCommitSha(owner, repoName, ref) + } + + private fun getGithubCommitSha(owner: String, repoName: String, ref: String): String? { + val apiUrl = "https://api.github.com/repos/$owner/$repoName/commits/${encodePathSegment(ref)}" + return try { + val url = URL(apiUrl) + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + setRequestProperty("Accept", "application/vnd.github.v3+json") + setRequestProperty("User-Agent", "Operit-Skill-Client") + connectTimeout = CONNECT_TIMEOUT + readTimeout = READ_TIMEOUT + } + if (connection.responseCode == HttpURLConnection.HTTP_OK) { + val response = connection.inputStream.bufferedReader().use { it.readText() } + val jsonObject = JsonParser.parseString(response).asJsonObject + jsonObject.get("sha")?.asString?.trim()?.takeIf { it.isNotEmpty() } + } else { + AppLogger.w(TAG, "GitHub commit lookup failed, HTTP ${connection.responseCode}") + null + } + } catch (e: Exception) { + AppLogger.w(TAG, "Failed to fetch GitHub commit SHA for $owner/$repoName@$ref", e) + null + } + } + + private fun githubZipKeyFile(skillDir: File): File { + return File(File(skillDir, OPERIT_META_DIR), GITHUB_ZIP_KEY_FILE) + } + + private fun writeGithubZipKey(skillDir: File, key: String) { + val file = githubZipKeyFile(skillDir) + file.parentFile?.mkdirs() + file.writeText(key) + } + + private fun readGithubZipKey(skillDir: File): String? { + val file = githubZipKeyFile(skillDir) + if (!file.isFile) return null + return file.readText().trim().takeIf { it.isNotEmpty() } + } + private fun getGithubDefaultBranch(owner: String, repoName: String): String? { val apiUrl = "https://api.github.com/repos/$owner/$repoName" return try { @@ -418,6 +494,7 @@ class SkillRepository private constructor(private val context: Context) { val connection = (url.openConnection() as HttpURLConnection).apply { requestMethod = "GET" setRequestProperty("Accept", "application/vnd.github.v3+json") + setRequestProperty("User-Agent", "Operit-Skill-Client") connectTimeout = CONNECT_TIMEOUT readTimeout = READ_TIMEOUT } diff --git a/app/src/main/java/com/ai/assistance/operit/util/SkillRepoZipPoolManager.kt b/app/src/main/java/com/ai/assistance/operit/util/SkillRepoZipPoolManager.kt index b5ee5d56a5..15d7aaacc1 100644 --- a/app/src/main/java/com/ai/assistance/operit/util/SkillRepoZipPoolManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/util/SkillRepoZipPoolManager.kt @@ -30,6 +30,12 @@ object SkillRepoZipPoolManager { } } + fun poolKey(owner: String, repo: String, identity: String): String { + return "${owner.trim()}/${repo.trim()}@${identity.trim()}" + } + + internal fun cacheFileName(key: String): String = "repo_${sha256Hex16(key)}.zip" + private fun sha256Hex16(value: String): String { val bytes = MessageDigest.getInstance("SHA-256") .digest(value.toByteArray(Charsets.UTF_8)) @@ -44,7 +50,7 @@ object SkillRepoZipPoolManager { return hex.take(16) } - private fun zipFileFor(dir: File, key: String): File = File(dir, "repo_${sha256Hex16(key)}.zip") + private fun zipFileFor(dir: File, key: String): File = File(dir, cacheFileName(key)) private fun partFileFor(dir: File, key: String): File = File(dir, "repo_${sha256Hex16(key)}.download") @@ -64,8 +70,18 @@ object SkillRepoZipPoolManager { } } + suspend fun invalidate(key: String) { + val dir = cacheDir ?: return + val mutex = keyMutexes.getOrPut(key) { Mutex() } + mutex.withLock { + runCatching { zipFileFor(dir, key).delete() } + runCatching { partFileFor(dir, key).delete() } + } + } + suspend fun getOrDownloadZip( key: String, + forceRefresh: Boolean = false, downloadTo: suspend (outFile: File) -> Boolean ): File? { val dir = cacheDir @@ -77,11 +93,15 @@ object SkillRepoZipPoolManager { val mutex = keyMutexes.getOrPut(key) { Mutex() } return mutex.withLock { val zipFile = zipFileFor(dir, key) - if (zipFile.exists() && zipFile.isFile && zipFile.length() > 0L) { + if (!forceRefresh && zipFile.exists() && zipFile.isFile && zipFile.length() > 0L) { AppLogger.d(TAG, "ZIP 命中缓存: key=$key, file=${zipFile.name}, bytes=${zipFile.length()}") touch(zipFile) return@withLock zipFile } + if (forceRefresh && zipFile.exists()) { + AppLogger.d(TAG, "ZIP 强制刷新,丢弃缓存: key=$key, file=${zipFile.name}") + runCatching { zipFile.delete() } + } AppLogger.d(TAG, "ZIP 缓存未命中,开始下载: key=$key") val partFile = partFileFor(dir, key) diff --git a/app/src/test/java/com/ai/assistance/operit/util/SkillRepoZipPoolManagerTest.kt b/app/src/test/java/com/ai/assistance/operit/util/SkillRepoZipPoolManagerTest.kt new file mode 100644 index 0000000000..0ded0e7ef5 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/util/SkillRepoZipPoolManagerTest.kt @@ -0,0 +1,97 @@ +package com.ai.assistance.operit.util + +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class SkillRepoZipPoolManagerTest { + + private var previousSystemLogEnabled = true + private lateinit var tempDir: File + + @Before + fun setUp() { + previousSystemLogEnabled = AppLogger.enableSystemLog + AppLogger.enableSystemLog = false + AppLogger.enableFileLogging = false + tempDir = createTempDir(prefix = "skill-zip-pool-") + SkillRepoZipPoolManager.initialize(tempDir) + } + + @After + fun tearDown() { + AppLogger.enableSystemLog = previousSystemLogEnabled + AppLogger.enableFileLogging = true + tempDir.deleteRecursively() + } + + @Test + fun poolKey_usesOwnerRepoAndIdentity() { + assertEquals( + "owner/repo@abc123", + SkillRepoZipPoolManager.poolKey(" owner ", " repo ", " abc123 ") + ) + } + + @Test + fun getOrDownloadZip_reusesCachedFileUntilForcedRefresh() = runBlocking { + val key = SkillRepoZipPoolManager.poolKey("owner", "repo", "sha-old") + var downloads = 0 + + val first = SkillRepoZipPoolManager.getOrDownloadZip(key) { outFile -> + downloads += 1 + outFile.writeText("old-zip") + true + } + val cached = SkillRepoZipPoolManager.getOrDownloadZip(key) { outFile -> + downloads += 1 + outFile.writeText("should-not-run") + true + } + val refreshed = SkillRepoZipPoolManager.getOrDownloadZip( + key = key, + forceRefresh = true + ) { outFile -> + downloads += 1 + outFile.writeText("new-zip") + true + } + + assertEquals(2, downloads) + assertEquals("old-zip", first?.readText()) + assertEquals("old-zip", cached?.readText()) + assertEquals("new-zip", refreshed?.readText()) + assertEquals( + SkillRepoZipPoolManager.cacheFileName(key), + refreshed?.name + ) + } + + @Test + fun invalidate_deletesCachedZipSoNextCallRedownloads() = runBlocking { + val key = SkillRepoZipPoolManager.poolKey("owner", "repo", "sha-1") + var downloads = 0 + + SkillRepoZipPoolManager.getOrDownloadZip(key) { outFile -> + downloads += 1 + outFile.writeText("cached") + true + } + SkillRepoZipPoolManager.invalidate(key) + val afterInvalidate = SkillRepoZipPoolManager.getOrDownloadZip(key) { outFile -> + downloads += 1 + outFile.writeText("fresh") + true + } + + assertEquals(2, downloads) + assertEquals("fresh", afterInvalidate?.readText()) + assertFalse(File(tempDir, "skill_repo_zip_pool/${SkillRepoZipPoolManager.cacheFileName("missing")}").exists()) + assertTrue(afterInvalidate!!.exists()) + } +} \ No newline at end of file