Fix content cache and clone-host safety

This commit is contained in:
shiaho
2026-07-28 10:32:47 +08:00
parent 9ea2bda0de
commit bec1a57872
4 changed files with 92 additions and 63 deletions
@@ -90,7 +90,7 @@ class ApkBuildCache(private val context: Context) {
}
fun shellTemplateId(templateApk: File): String {
return "name=${templateApk.name}|size=${templateApk.length()}"
return "sha256=${fileSha256(templateApk)}"
}
fun plan(
@@ -388,27 +388,44 @@ class ApkBuildCache(private val context: Context) {
private fun fileFingerprint(path: String?): String {
if (path.isNullOrBlank()) return "none"
val file = File(path)
if (!file.isFile) return "missing:$path"
return "file:${file.absolutePath}|${file.length()}|${file.lastModified()}"
if (!file.isFile) return "missing"
return "sha256=${fileSha256(file)}"
}
private fun treeFingerprint(dir: File): String {
if (!dir.isDirectory) return "missing:${dir.absolutePath}"
if (!dir.isDirectory) return "missing"
val digest = MessageDigest.getInstance("SHA-256")
dir.walkTopDown()
.filter { it.isFile }
.sortedBy { it.relativeTo(dir).path }
.sortedBy { it.relativeTo(dir).invariantSeparatorsPath }
.forEach { file ->
val rel = file.relativeTo(dir).path
digest.update(rel.toByteArray())
digest.update(file.length().toString().toByteArray())
digest.update(file.lastModified().toString().toByteArray())
val relativePath = file.relativeTo(dir).invariantSeparatorsPath
digest.update(relativePath.toByteArray(Charsets.UTF_8))
digest.update(0)
digest.update(fileSha256(file).toByteArray(Charsets.UTF_8))
digest.update(0)
}
return digest.digest().joinToString("") { "%02x".format(it) }
return digest.digest().toHexString()
}
private fun fileSha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
}
}
return digest.digest().toHexString()
}
private fun sha256(text: String): String {
val digest = MessageDigest.getInstance("SHA-256")
return digest.digest(text.toByteArray()).joinToString("") { "%02x".format(it) }
return digest.digest(text.toByteArray(Charsets.UTF_8)).toHexString()
}
private fun ByteArray.toHexString(): String =
joinToString("") { "%02x".format(it) }
}
@@ -259,6 +259,14 @@ class AppCloner(private val context: Context) {
safeProgress(20, "修改包名和应用名...")
val cloneHostDex = loadCloneHostDex()
if (hasModifications && cloneHostDex == null) {
AppLogger.w("AppCloner", "Clone-host enhancements requested but clone_host.dex is unavailable")
return@withContext AppModifyResult.Error(
"启动页、激活和公告增强当前不可用;请移除这些增强设置后重试"
)
}
var iconBitmap: Bitmap? = null
try {
iconBitmap = config.newIconPath?.let { loadBitmapFromPath(it) }
@@ -267,11 +275,6 @@ class AppCloner(private val context: Context) {
AppLogger.e("AppCloner", "Failed to load icon: ${e.message}")
}
val cloneHostDex = loadCloneHostDex()
if (hasModifications && cloneHostDex == null) {
AppLogger.w("AppCloner", "Modifications requested but clone_host.dex not found; proceeding without injection")
}
val splashMediaPath = if (CloneConfigBuilder.needsSplashMedia(config)) {
CloneConfigBuilder.getSplashMediaPath(config)
} else null
@@ -73,17 +73,17 @@ class ApkBuildCacheTest {
}
@Test
fun `shell template id changes with size but not mtime`() {
fun `shell template id tracks content rather than mtime or size`() {
val cache = ApkBuildCache(RuntimeEnvironment.getApplication())
val file = File(RuntimeEnvironment.getApplication().cacheDir, "t.apk")
file.writeBytes(ByteArray(10))
val a = cache.shellTemplateId(file)
file.writeBytes(byteArrayOf(1, 2, 3, 4))
val initial = cache.shellTemplateId(file)
file.setLastModified(file.lastModified() + 60_000L)
val same = cache.shellTemplateId(file)
assertThat(same).isEqualTo(a)
file.writeBytes(ByteArray(20))
val b = cache.shellTemplateId(file)
assertThat(a).isNotEqualTo(b)
assertThat(cache.shellTemplateId(file)).isEqualTo(initial)
file.writeBytes(byteArrayOf(4, 3, 2, 1))
assertThat(cache.shellTemplateId(file)).isNotEqualTo(initial)
}
@Test
@@ -119,7 +119,7 @@ class CloneLauncherActivity : Activity() {
if (cfg.remoteActivationEnabled) {
Thread {
val activated = isActivated(appId) && checkRemoteActivation(cfg)
val activated = isActivated(appId) && checkRemoteActivation(cfg, appId)
handler.post {
if (activated) {
proceedAfterActivation(cfg)
@@ -181,7 +181,7 @@ class CloneLauncherActivity : Activity() {
return@setOnClickListener
}
Thread {
val result = verifyCode(cfg, code)
val result = verifyCode(cfg, code, appId)
handler.post {
if (result) {
saveActivation(appId)
@@ -195,9 +195,9 @@ class CloneLauncherActivity : Activity() {
}
}
private fun verifyCode(cfg: CloneConfig, inputCode: String): Boolean {
private fun verifyCode(cfg: CloneConfig, inputCode: String, appId: Long): Boolean {
if (cfg.remoteActivationEnabled) {
return verifyRemoteCode(cfg, inputCode)
return verifyRemoteCode(cfg, inputCode, appId)
}
val normalized = normalizeCode(inputCode)
return cfg.activationCodes.any { validCode ->
@@ -207,55 +207,64 @@ class CloneLauncherActivity : Activity() {
}
}
private fun verifyRemoteCode(cfg: CloneConfig, inputCode: String): Boolean {
private fun verifyRemoteCode(cfg: CloneConfig, inputCode: String, appId: Long): Boolean {
return try {
val deviceId = generateDeviceId()
val normalized = normalizeCode(inputCode)
val conn = (URL(cfg.remoteVerifyUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
setRequestProperty("Content-Type", "application/json")
connectTimeout = 10000
readTimeout = 10000
}
val requestBody = """{"code":"$normalized","deviceId":"$deviceId","packageName":"$packageName"}"""
conn.outputStream.use { it.write(requestBody.toByteArray()) }
if (conn.responseCode == 200) {
val conn = openRemoteConnection(cfg.remoteVerifyUrl, 10_000) ?: return offlineAllowed(cfg, appId)
val requestBody = JSONObject()
.put("code", normalizeCode(inputCode))
.put("deviceId", generateDeviceId())
.put("packageName", packageName)
.toString()
conn.outputStream.use { it.write(requestBody.toByteArray(Charsets.UTF_8)) }
if (conn.responseCode == HttpURLConnection.HTTP_OK) {
val response = conn.inputStream.bufferedReader().use { it.readText() }
val result = JSONObject(response)
result.optBoolean("success", false)
JSONObject(response).optBoolean("success", false)
} else {
cfg.remoteOfflinePolicy == "ALLOW"
offlineAllowed(cfg, appId)
}
} catch (e: Exception) {
cfg.remoteOfflinePolicy == "ALLOW" || cfg.remoteOfflinePolicy == "ALLOW_CACHED"
} catch (_: Exception) {
offlineAllowed(cfg, appId)
}
}
private fun checkRemoteActivation(cfg: CloneConfig): Boolean {
private fun checkRemoteActivation(cfg: CloneConfig, appId: Long): Boolean {
return try {
val deviceId = generateDeviceId()
val conn = (URL(cfg.remoteVerifyUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
setRequestProperty("Content-Type", "application/json")
connectTimeout = 5000
readTimeout = 5000
}
val requestBody = """{"code":"","deviceId":"$deviceId","packageName":"$packageName","checkOnly":true}"""
conn.outputStream.use { it.write(requestBody.toByteArray()) }
if (conn.responseCode == 200) {
val conn = openRemoteConnection(cfg.remoteVerifyUrl, 5_000) ?: return offlineAllowed(cfg, appId)
val requestBody = JSONObject()
.put("code", "")
.put("deviceId", generateDeviceId())
.put("packageName", packageName)
.put("checkOnly", true)
.toString()
conn.outputStream.use { it.write(requestBody.toByteArray(Charsets.UTF_8)) }
if (conn.responseCode == HttpURLConnection.HTTP_OK) {
val response = conn.inputStream.bufferedReader().use { it.readText() }
val result = JSONObject(response)
result.optBoolean("success", false)
JSONObject(response).optBoolean("success", false)
} else {
cfg.remoteOfflinePolicy == "ALLOW_CACHED"
offlineAllowed(cfg, appId)
}
} catch (e: Exception) {
cfg.remoteOfflinePolicy == "ALLOW_CACHED" || cfg.remoteOfflinePolicy == "ALLOW"
} catch (_: Exception) {
offlineAllowed(cfg, appId)
}
}
private fun openRemoteConnection(url: String, timeoutMs: Int): HttpURLConnection? {
if (!url.startsWith("https://", ignoreCase = true)) return null
return (URL(url).openConnection() as? HttpURLConnection)?.apply {
requestMethod = "POST"
doOutput = true
setRequestProperty("Content-Type", "application/json")
connectTimeout = timeoutMs
readTimeout = timeoutMs
}
}
private fun offlineAllowed(cfg: CloneConfig, appId: Long): Boolean = when (cfg.remoteOfflinePolicy) {
"ALLOW" -> true
"ALLOW_CACHED" -> isActivated(appId)
else -> false
}
private fun proceedAfterActivation(cfg: CloneConfig) {
if (cfg.announcementEnabled && cfg.announcementTitle.isNotBlank()) {
showAnnouncement(cfg)