feat(appmodifier): inject splash/activation/announcement into cloned APKs via DEX injection

Cloned APKs previously only had package name/icon/name changed without
any runtime configuration. This adds a clone-host Gradle module with a
pure-SDK CloneLauncherActivity (zero external deps for max compatibility),
compiles it to DEX via d8, injects it as classesN.dex into the target APK,
and rewrites AndroidManifest to replace the launcher activity.

CloneLauncherActivity reads clone_config.json from assets and applies
splash (image countdown / video), activation code verification (local
SHA-256 + remote HTTP), and announcement dialog before launching the
original app via setClassName.
This commit is contained in:
shiaho
2026-06-17 17:30:27 +08:00
parent c1a24ea41f
commit 033f3dd94d
8 changed files with 1600 additions and 53 deletions
+68
View File
@@ -1,4 +1,5 @@
import java.util.Properties
import java.util.zip.ZipFile
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
@@ -195,6 +196,73 @@ tasks.matching { it.name == "preBuild" }.configureEach {
}
}
val cloneHostAar = project(":clone-host").layout.buildDirectory.file("outputs/aar/clone-host-release.aar")
tasks.register<Copy>("syncCloneHostDex") {
description = "Extracts classes.jar from clone-host AAR and converts it to a DEX asset for APK cloning."
group = "build"
dependsOn(":clone-host:assembleRelease")
val dexOutputDir = file("src/main/assets/clone_host")
val intermediateDir = layout.buildDirectory.dir("intermediates/clone-host-extract")
doFirst {
intermediateDir.get().asFile.mkdirs()
dexOutputDir.mkdirs()
}
from(cloneHostAar)
into(intermediateDir)
rename { "clone-host.aar" }
doLast {
val aarFile = intermediateDir.get().file("clone-host.aar").asFile
if (!aarFile.exists()) {
throw GradleException("clone-host AAR not found at ${aarFile.absolutePath}")
}
val classesJar = intermediateDir.get().file("classes.jar").asFile
ZipFile(aarFile).use { zip ->
val entry = zip.getEntry("classes.jar")
?: throw GradleException("classes.jar not found in clone-host AAR")
zip.getInputStream(entry).use { input ->
classesJar.outputStream().use { output -> input.copyTo(output) }
}
}
val androidJar = android.sdkDirectory.resolve("platforms/android-36/android.jar")
if (!androidJar.exists()) {
throw GradleException("android.jar not found at ${androidJar.absolutePath}")
}
val d8 = android.sdkDirectory.resolve("build-tools")
.listFiles()?.maxByOrNull { it.name }
?.resolve("d8")
?: throw GradleException("d8 not found in build-tools")
val dexFile = file("src/main/assets/clone_host/clone_host.dex")
dexFile.parentFile.mkdirs()
val process = ProcessBuilder(
d8.absolutePath,
"--release",
"--min-api", "23",
"--lib", androidJar.absolutePath,
"--output", dexOutputDir.absolutePath,
classesJar.absolutePath
).redirectErrorStream(true).start()
val output = process.inputStream.bufferedReader().readText()
if (process.waitFor() != 0) {
throw GradleException("d8 failed to dex clone-host: $output")
}
}
}
tasks.matching { it.name == "preBuild" }.configureEach {
dependsOn("syncCloneHostDex")
}
tasks.register("testClasses") {
group = "verification"
description = "Compatibility alias for JVM-style test class compilation in the Android app module."
@@ -214,6 +214,8 @@ class AppCloner(private val context: Context) {
}
}
private val manifestRewriter = CloneManifestRewriter()
suspend fun cloneAndInstall(
config: AppModifyConfig,
onProgress: (Int, String) -> Unit = { _, _ -> }
@@ -244,6 +246,9 @@ class AppCloner(private val context: Context) {
val newPackageName = generateClonePackageName(config.originalApp.packageName)
AppLogger.d("AppCloner", "New package name: $newPackageName")
val hasModifications = CloneConfigBuilder.hasAnyModification(config)
AppLogger.d("AppCloner", "Has modifications: $hasModifications")
safeProgress(10, "复制 APK...")
val unsignedApk = File(tempDir, "clone_unsigned.apk")
@@ -260,10 +265,24 @@ class AppCloner(private val context: Context) {
?: config.originalApp.icon?.let { drawableToBitmap(it) }
} catch (e: Exception) {
AppLogger.e("AppCloner", "Failed to load icon: ${e.message}")
}
AppLogger.d("AppCloner", "Modifying APK...")
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
val configJson = if (hasModifications) {
CloneConfigBuilder.buildJson(config)
} else null
val splashType = CloneConfigBuilder.getSplashType(config)
AppLogger.d("AppCloner", "Modifying APK with injection: dex=${cloneHostDex != null}, config=${configJson != null}, splash=${splashMediaPath != null}")
modifyApk(
sourceApk = sourceApk,
outputApk = unsignedApk,
@@ -271,7 +290,12 @@ class AppCloner(private val context: Context) {
newPackageName = newPackageName,
originalAppName = config.originalApp.appName,
newAppName = config.newAppName,
iconBitmap = iconBitmap
iconBitmap = iconBitmap,
injectDex = cloneHostDex,
injectConfigJson = configJson,
injectSplashMediaPath = splashMediaPath,
injectSplashType = splashType,
useManifestRewriter = hasModifications && cloneHostDex != null
) { progress ->
}
@@ -312,6 +336,15 @@ class AppCloner(private val context: Context) {
}
}
private fun loadCloneHostDex(): ByteArray? {
return try {
context.assets.open("clone_host/clone_host.dex").use { it.readBytes() }
} catch (e: Exception) {
AppLogger.w("AppCloner", "clone_host.dex asset not found: ${e.message}")
null
}
}
private fun generateClonePackageName(originalPackageName: String): String {
val maxLen = originalPackageName.length.coerceAtMost(128)
@@ -351,18 +384,37 @@ class AppCloner(private val context: Context) {
originalAppName: String,
newAppName: String,
iconBitmap: Bitmap?,
injectDex: ByteArray? = null,
injectConfigJson: String? = null,
injectSplashMediaPath: String? = null,
injectSplashType: String = "IMAGE",
useManifestRewriter: Boolean = false,
onProgress: (Int) -> Unit = {}
) {
var originalLauncherActivity: String? = null
ZipFile(sourceApk).use { zipIn ->
ZipOutputStream(FileOutputStream(outputApk)).use { zipOut ->
val entries = zipIn.entries().toList()
.sortedWith(compareBy<ZipEntry> { it.name != "resources.arsc" })
val entryNames = entries.map { it.name }.toSet()
val existingDexNumbers = entryNames.mapNotNull { name ->
if (name.matches(Regex("^classes\\d*\\.dex$"))) {
if (name == "classes.dex") 1
else name.removePrefix("classes").removeSuffix(".dex").toIntOrNull()
} else null
}.sorted()
val nextDexNumber = (existingDexNumbers.maxOrNull() ?: 0) + 1
val newDexName = "classes${nextDexNumber}.dex"
AppLogger.d("AppCloner", "Injecting DEX as: $newDexName (existing: $existingDexNumbers)")
val allEntryCount = entries.size + (if (injectDex != null) 1 else 0)
var processedCount = 0
entries.forEach { entry ->
processedCount++
onProgress((processedCount * 100) / entries.size)
onProgress((processedCount * 100) / allEntryCount)
when {
@@ -376,11 +428,17 @@ class AppCloner(private val context: Context) {
val originalData = zipIn.getInputStream(entry).readBytes()
AppLogger.d("AppCloner", "AndroidManifest.xml original size: ${originalData.size} bytes")
val modifiedData = if (originalPackageName == "com.webtoapp") {
axmlEditor.modifyPackageName(originalData, newPackageName)
val modifiedData: ByteArray
if (useManifestRewriter) {
AppLogger.d("AppCloner", "Using CloneManifestRewriter for injection")
val rewriteResult = manifestRewriter.rewrite(originalData, originalPackageName, newPackageName)
modifiedData = rewriteResult.axmlData
originalLauncherActivity = rewriteResult.originalLauncherActivity
AppLogger.d("AppCloner", "Original launcher activity: $originalLauncherActivity")
} else if (originalPackageName == "com.webtoapp") {
modifiedData = axmlEditor.modifyPackageName(originalData, newPackageName)
} else {
axmlRebuilder.expandAndModify(
modifiedData = axmlRebuilder.expandAndModify(
originalData,
originalPackageName,
newPackageName
@@ -391,7 +449,6 @@ class AppCloner(private val context: Context) {
writeEntryDeflated(zipOut, entry.name, modifiedData)
} catch (e: Exception) {
AppLogger.e("AppCloner", "Failed to modify AndroidManifest.xml: ${e.message}", e)
copyEntry(zipIn, zipOut, entry)
}
}
@@ -410,11 +467,22 @@ class AppCloner(private val context: Context) {
writeEntryStored(zipOut, entry.name, modifiedData)
} catch (e: Exception) {
AppLogger.e("AppCloner", "Failed to modify resources.arsc: ${e.message}", e)
copyEntry(zipIn, zipOut, entry)
}
}
injectConfigJson != null && entry.name == "assets/clone_config.json" -> {
AppLogger.d("AppCloner", "Replacing existing clone_config.json")
writeEntryDeflated(zipOut, entry.name, injectConfigJson.toByteArray(Charsets.UTF_8))
}
injectSplashMediaPath != null && (
entry.name == "assets/splash_media.png" ||
entry.name == "assets/splash_media.mp4"
) -> {
AppLogger.d("AppCloner", "Skipping existing splash media: ${entry.name}")
}
iconBitmap != null && isIconEntry(entry.name) -> {
replaceIconEntry(zipOut, entry.name, iconBitmap)
}
@@ -425,6 +493,45 @@ class AppCloner(private val context: Context) {
}
}
if (injectDex != null) {
AppLogger.d("AppCloner", "Injecting DEX: $newDexName (${injectDex.size} bytes)")
writeEntryDeflated(zipOut, newDexName, injectDex)
processedCount++
onProgress((processedCount * 100) / allEntryCount)
}
if (injectConfigJson != null) {
val finalConfigJson = if (originalLauncherActivity != null) {
try {
val obj = com.google.gson.JsonParser.parseString(injectConfigJson).asJsonObject
obj.addProperty("originalLauncherActivity", originalLauncherActivity)
obj.toString()
} catch (e: Exception) {
injectConfigJson
}
} else {
injectConfigJson
}
AppLogger.d("AppCloner", "Injecting clone_config.json (${finalConfigJson.length} chars)")
writeEntryDeflated(zipOut, "assets/clone_config.json", finalConfigJson.toByteArray(Charsets.UTF_8))
}
if (injectSplashMediaPath != null) {
val splashFile = File(injectSplashMediaPath)
if (splashFile.exists()) {
val splashAssetName = if (injectSplashType == "VIDEO") "assets/splash_media.mp4" else "assets/splash_media.png"
AppLogger.d("AppCloner", "Injecting splash media: $splashAssetName from ${splashFile.absolutePath} (${splashFile.length()} bytes)")
val splashBytes = splashFile.readBytes()
if (injectSplashType == "VIDEO") {
writeEntryStored(zipOut, splashAssetName, splashBytes)
} else {
writeEntryDeflated(zipOut, splashAssetName, splashBytes)
}
} else {
AppLogger.w("AppCloner", "Splash media file not found: ${splashFile.absolutePath}")
}
}
if (iconBitmap != null &&
entryNames.contains("res/drawable/ic_launcher_foreground.xml")
) {
@@ -434,49 +541,6 @@ class AppCloner(private val context: Context) {
}
}
private fun modifyManifestPackageName(
data: ByteArray,
oldPackage: String,
newPackage: String
): ByteArray {
val result = data.copyOf()
replacePackageBytes(result, oldPackage, newPackage, Charsets.UTF_8)
replacePackageBytes(result, oldPackage, newPackage, Charsets.UTF_16LE)
return result
}
private fun replacePackageBytes(data: ByteArray, oldPkg: String, newPkg: String, charset: java.nio.charset.Charset) {
val oldBytes = oldPkg.toByteArray(charset)
val newBytes = newPkg.toByteArray(charset)
val replacement = if (newBytes.size <= oldBytes.size) {
newBytes + ByteArray(oldBytes.size - newBytes.size)
} else {
newBytes.copyOf(oldBytes.size)
}
var i = 0
while (i <= data.size - oldBytes.size) {
var match = true
for (j in oldBytes.indices) {
if (data[i + j] != oldBytes[j]) {
match = false
break
}
}
if (match) {
System.arraycopy(replacement, 0, data, i, replacement.size)
i += oldBytes.size
} else {
i++
}
}
}
private fun isIconEntry(entryName: String): Boolean {
if (ICON_PATHS.any { it.first == entryName } ||
@@ -0,0 +1,73 @@
package com.webtoapp.core.appmodifier
import com.webtoapp.data.model.SplashType
import com.webtoapp.data.model.SplashOrientation
import com.webtoapp.util.GsonProvider
object CloneConfigBuilder {
fun buildJson(config: AppModifyConfig): String {
val splashConfig = config.splashConfig
val splashType = if (splashConfig.type == SplashType.VIDEO) "VIDEO" else "IMAGE"
val splashOrientation = if (splashConfig.orientation == SplashOrientation.LANDSCAPE) "LANDSCAPE" else "PORTRAIT"
val activationDialog = mapOf(
"title" to config.activationDialogConfig.title,
"subtitle" to config.activationDialogConfig.subtitle,
"inputLabel" to config.activationDialogConfig.inputLabel,
"buttonText" to config.activationDialogConfig.buttonText
)
val remoteConfig = config.activationRemoteConfig
val announcement = config.announcement
val cloneConfig = mapOf(
"targetPackage" to config.originalApp.packageName,
"splashEnabled" to (config.splashEnabled && !splashConfig.mediaPath.isNullOrBlank()),
"splashType" to splashType,
"splashDuration" to splashConfig.duration,
"splashClickToSkip" to splashConfig.clickToSkip,
"splashOrientation" to splashOrientation,
"splashFillScreen" to splashConfig.fillScreen,
"splashEnableAudio" to splashConfig.enableAudio,
"splashVideoStartMs" to splashConfig.videoStartMs,
"splashVideoEndMs" to splashConfig.videoEndMs,
"activationEnabled" to config.activationEnabled,
"activationCodes" to config.activationCodes.map { it.code },
"activationRequireEveryTime" to config.activationRequireEveryTime,
"activationDialog" to activationDialog,
"remoteActivationEnabled" to remoteConfig.enabled,
"remoteVerifyUrl" to remoteConfig.verifyUrl,
"remoteOfflinePolicy" to remoteConfig.offlinePolicy.name,
"announcementEnabled" to config.announcementEnabled,
"announcementTitle" to announcement.title,
"announcementContent" to announcement.content,
"announcementContentIsHtml" to announcement.contentIsHtml,
"announcementLinkUrl" to (announcement.linkUrl ?: ""),
"announcementLinkText" to (announcement.linkText ?: "")
)
return GsonProvider.gson.toJson(cloneConfig)
}
fun needsSplashMedia(config: AppModifyConfig): Boolean {
return config.splashEnabled && !config.splashConfig.mediaPath.isNullOrBlank()
}
fun getSplashMediaPath(config: AppModifyConfig): String? {
return config.splashConfig.mediaPath
}
fun getSplashType(config: AppModifyConfig): String {
return if (config.splashConfig.type == SplashType.VIDEO) "VIDEO" else "IMAGE"
}
fun hasAnyModification(config: AppModifyConfig): Boolean {
val hasSplash = config.splashEnabled && !config.splashConfig.mediaPath.isNullOrBlank()
val hasActivation = config.activationEnabled &&
(config.activationCodes.isNotEmpty() || config.activationRemoteConfig.enabled)
val hasAnnouncement = config.announcementEnabled && config.announcement.title.isNotBlank()
return hasSplash || hasActivation || hasAnnouncement
}
}
@@ -0,0 +1,769 @@
package com.webtoapp.core.appmodifier
import com.webtoapp.core.logging.AppLogger
import java.nio.ByteBuffer
import java.nio.ByteOrder
class CloneManifestRewriter {
companion object {
private const val TAG = "CloneManifestRewriter"
private const val CHUNK_AXML_FILE = 0x0003
private const val CHUNK_STRING_POOL = 0x0001
private const val CHUNK_RESOURCE_MAP = 0x0180
private const val CHUNK_START_NAMESPACE = 0x0100
private const val CHUNK_END_NAMESPACE = 0x0101
private const val CHUNK_START_ELEMENT = 0x0102
private const val CHUNK_END_ELEMENT = 0x0103
private const val ATTR_NAME = 0x01010003
private const val ATTR_EXPORTED = 0x01010010
private const val CLONE_LAUNCHER_CLASS = "com.webtoapp.clone.CloneLauncherActivity"
}
data class RewriteResult(
val axmlData: ByteArray,
val originalLauncherActivity: String?
)
fun rewrite(
axmlData: ByteArray,
originalPackage: String,
newPackage: String
): RewriteResult {
return try {
val parsed = parseAxml(axmlData) ?: return RewriteResult(axmlData, null)
val expansions = findRelativeClassNames(parsed, originalPackage)
if (expansions.isNotEmpty()) {
expandClassNames(parsed, expansions)
}
replacePackageString(parsed, originalPackage, newPackage)
val originalLauncher = findOriginalLauncherActivity(parsed, originalPackage)
removeLauncherIntentFilters(parsed)
addCloneLauncherActivity(parsed, newPackage)
val result = rebuildAxml(parsed)
AppLogger.d(TAG, "Clone manifest rewrite: ${axmlData.size} -> ${result.size} bytes, originalLauncher=$originalLauncher")
RewriteResult(result, originalLauncher)
} catch (e: Exception) {
AppLogger.e(TAG, "Clone manifest rewrite failed", e)
RewriteResult(axmlData, null)
}
}
private fun findOriginalLauncherActivity(parsed: ParsedAxml, originalPackage: String): String? {
val resourceMap = parsed.resourceMap ?: return null
val nameAttrIndex = resourceMap.indexOf(ATTR_NAME)
if (nameAttrIndex < 0) return null
val mainActionIndex = parsed.stringPool.strings.indexOf("android.intent.action.MAIN")
val launcherCatIndex = parsed.stringPool.strings.indexOf("android.intent.category.LAUNCHER")
val intentFilterStrIndex = parsed.stringPool.strings.indexOf("intent-filter")
val actionStrIndex = parsed.stringPool.strings.indexOf("action")
val categoryStrIndex = parsed.stringPool.strings.indexOf("category")
val activityStrIndex = parsed.stringPool.strings.indexOf("activity")
if (mainActionIndex < 0 || launcherCatIndex < 0 || intentFilterStrIndex < 0 || activityStrIndex < 0) return null
var currentActivityName: String? = null
for (i in parsed.chunks.indices) {
val chunk = parsed.chunks[i]
if (chunk.type == CHUNK_START_ELEMENT) {
val elementNameStr = parsed.stringPool.strings.getOrNull(readElementNameIndex(chunk))
if (elementNameStr == "activity") {
currentActivityName = readStringAttribute(parsed, chunk, nameAttrIndex)
}
}
if (chunk.type == CHUNK_START_ELEMENT && currentActivityName != null) {
val elementNameStr = parsed.stringPool.strings.getOrNull(readElementNameIndex(chunk))
if (elementNameStr == "intent-filter") {
val hasMainAndLauncher = intentFilterContainsMainAndLauncher(
parsed, i,
actionStrIndex, categoryStrIndex,
mainActionIndex, launcherCatIndex, nameAttrIndex
)
if (hasMainAndLauncher) {
val result = currentActivityName
if (result != null && result.startsWith(".")) {
return originalPackage + result
}
return result
}
}
}
}
return null
}
private fun removeLauncherIntentFilters(parsed: ParsedAxml) {
val resourceMap = parsed.resourceMap ?: return
val nameAttrIndex = resourceMap.indexOf(ATTR_NAME)
if (nameAttrIndex < 0) return
val mainActionIndex = parsed.stringPool.strings.indexOf("android.intent.action.MAIN")
val launcherCatIndex = parsed.stringPool.strings.indexOf("android.intent.category.LAUNCHER")
val intentFilterStrIndex = parsed.stringPool.strings.indexOf("intent-filter")
val actionStrIndex = parsed.stringPool.strings.indexOf("action")
val categoryStrIndex = parsed.stringPool.strings.indexOf("category")
if (mainActionIndex < 0 || launcherCatIndex < 0 || intentFilterStrIndex < 0) return
val indicesToRemove = mutableSetOf<Int>()
var i = 0
while (i < parsed.chunks.size) {
val chunk = parsed.chunks[i]
if (chunk.type == CHUNK_START_ELEMENT) {
val elementNameIndex = readElementNameIndex(chunk)
val elementNameStr = parsed.stringPool.strings.getOrNull(elementNameIndex)
if (elementNameStr == "intent-filter" && intentFilterContainsMainAndLauncher(parsed, i, actionStrIndex, categoryStrIndex, mainActionIndex, launcherCatIndex, nameAttrIndex)) {
val endIndex = findMatchingEndElementIndex(parsed, i)
if (endIndex > i) {
for (idx in i..endIndex) {
indicesToRemove.add(idx)
}
i = endIndex + 1
continue
}
}
}
i++
}
if (indicesToRemove.isNotEmpty()) {
for (idx in indicesToRemove.sortedDescending()) {
parsed.chunks.removeAt(idx)
}
AppLogger.d(TAG, "Removed ${indicesToRemove.size} chunks from launcher intent-filters")
}
}
private fun intentFilterContainsMainAndLauncher(
parsed: ParsedAxml,
startIndex: Int,
actionStrIndex: Int,
categoryStrIndex: Int,
mainActionIndex: Int,
launcherCatIndex: Int,
nameAttrIndex: Int
): Boolean {
val endIndex = findMatchingEndElementIndex(parsed, startIndex)
if (endIndex < 0) return false
var hasMain = false
var hasLauncher = false
for (i in (startIndex + 1) until endIndex) {
val chunk = parsed.chunks[i]
if (chunk.type != CHUNK_START_ELEMENT) continue
val elementNameIndex = readElementNameIndex(chunk)
val elementNameStr = parsed.stringPool.strings.getOrNull(elementNameIndex)
if (elementNameStr == "action" && actionStrIndex >= 0) {
val nameValue = readStringAttribute(parsed, chunk, nameAttrIndex)
if (nameValue == "android.intent.action.MAIN") {
hasMain = true
}
} else if (elementNameStr == "category" && categoryStrIndex >= 0) {
val nameValue = readStringAttribute(parsed, chunk, nameAttrIndex)
if (nameValue == "android.intent.category.LAUNCHER") {
hasLauncher = true
}
}
}
return hasMain && hasLauncher
}
private fun addCloneLauncherActivity(parsed: ParsedAxml, packageName: String) {
val resourceMap = parsed.resourceMap ?: return
val nameAttrIndex = resourceMap.indexOf(ATTR_NAME)
val exportedAttrIndex = resourceMap.indexOf(ATTR_EXPORTED)
if (nameAttrIndex < 0 || exportedAttrIndex < 0) {
AppLogger.e(TAG, "Missing required attribute indices for CloneLauncherActivity")
return
}
val androidNsIndex = getOrAddString(parsed.stringPool, "http://schemas.android.com/apk/res/android")
val activityStrIndex = getOrAddString(parsed.stringPool, "activity")
val intentFilterStrIndex = getOrAddString(parsed.stringPool, "intent-filter")
val actionStrIndex = getOrAddString(parsed.stringPool, "action")
val categoryStrIndex = getOrAddString(parsed.stringPool, "category")
val mainActionIndex = getOrAddString(parsed.stringPool, "android.intent.action.MAIN")
val launcherCatIndex = getOrAddString(parsed.stringPool, "android.intent.category.LAUNCHER")
val cloneActivityIndex = getOrAddString(parsed.stringPool, CLONE_LAUNCHER_CLASS)
val appEndIndex = findApplicationEndIndex(parsed)
if (appEndIndex < 0) {
AppLogger.e(TAG, "Cannot find </application> for CloneLauncherActivity injection")
return
}
val newChunks = mutableListOf<Chunk>()
val activityStart = buildActivityStartElement(
androidNsIndex = androidNsIndex,
elementNameIndex = activityStrIndex,
nameAttrIndex = nameAttrIndex,
nameValueIndex = cloneActivityIndex,
exportedAttrIndex = exportedAttrIndex
)
newChunks.add(activityStart)
newChunks.add(buildSimpleStartElement(androidNsIndex, intentFilterStrIndex, 0))
newChunks.add(buildActionOrCategoryElement(androidNsIndex, actionStrIndex, nameAttrIndex, mainActionIndex))
newChunks.add(buildEndElement(androidNsIndex, actionStrIndex))
newChunks.add(buildActionOrCategoryElement(androidNsIndex, categoryStrIndex, nameAttrIndex, launcherCatIndex))
newChunks.add(buildEndElement(androidNsIndex, categoryStrIndex))
newChunks.add(buildEndElement(androidNsIndex, intentFilterStrIndex))
newChunks.add(buildEndElement(androidNsIndex, activityStrIndex))
parsed.chunks.addAll(appEndIndex, newChunks)
AppLogger.d(TAG, "Injected CloneLauncherActivity with ${newChunks.size} chunks")
}
private fun buildActivityStartElement(
androidNsIndex: Int,
elementNameIndex: Int,
nameAttrIndex: Int,
nameValueIndex: Int,
exportedAttrIndex: Int
): Chunk {
val attrCount = 2
val attrSize = 20
val headerSize = 16
val attrStart = 20
val chunkSize = 36 + attrCount * attrSize
val buffer = ByteBuffer.allocate(chunkSize).order(ByteOrder.LITTLE_ENDIAN)
buffer.putShort(CHUNK_START_ELEMENT.toShort())
buffer.putShort(headerSize.toShort())
buffer.putInt(chunkSize)
buffer.putInt(0)
buffer.putInt(-1)
buffer.putInt(-1)
buffer.putInt(elementNameIndex)
buffer.putShort(attrStart.toShort())
buffer.putShort(attrSize.toShort())
buffer.putShort(attrCount.toShort())
buffer.putShort(0)
buffer.putShort(0)
buffer.putShort(0)
buffer.putInt(androidNsIndex)
buffer.putInt(nameAttrIndex)
buffer.putInt(nameValueIndex)
buffer.putShort(8)
buffer.put(0)
buffer.put(0x03)
buffer.putInt(nameValueIndex)
buffer.putInt(androidNsIndex)
buffer.putInt(exportedAttrIndex)
buffer.putInt(-1)
buffer.putShort(8)
buffer.put(0)
buffer.put(0x12)
buffer.putInt(-1)
return Chunk(CHUNK_START_ELEMENT, 0, chunkSize, buffer.array())
}
private fun buildSimpleStartElement(androidNsIndex: Int, elementNameIndex: Int, attrCount: Int): Chunk {
val attrSize = 20
val headerSize = 16
val chunkSize = 36 + attrCount * attrSize
val buffer = ByteBuffer.allocate(chunkSize).order(ByteOrder.LITTLE_ENDIAN)
buffer.putShort(CHUNK_START_ELEMENT.toShort())
buffer.putShort(headerSize.toShort())
buffer.putInt(chunkSize)
buffer.putInt(0)
buffer.putInt(-1)
buffer.putInt(-1)
buffer.putInt(elementNameIndex)
buffer.putShort(20)
buffer.putShort(attrSize.toShort())
buffer.putShort(attrCount.toShort())
buffer.putShort(0)
buffer.putShort(0)
buffer.putShort(0)
return Chunk(CHUNK_START_ELEMENT, 0, chunkSize, buffer.array())
}
private fun buildActionOrCategoryElement(
androidNsIndex: Int,
elementNameIndex: Int,
nameAttrIndex: Int,
nameValueIndex: Int
): Chunk {
val attrCount = 1
val attrSize = 20
val headerSize = 16
val chunkSize = 36 + attrCount * attrSize
val buffer = ByteBuffer.allocate(chunkSize).order(ByteOrder.LITTLE_ENDIAN)
buffer.putShort(CHUNK_START_ELEMENT.toShort())
buffer.putShort(headerSize.toShort())
buffer.putInt(chunkSize)
buffer.putInt(0)
buffer.putInt(-1)
buffer.putInt(-1)
buffer.putInt(elementNameIndex)
buffer.putShort(20)
buffer.putShort(attrSize.toShort())
buffer.putShort(attrCount.toShort())
buffer.putShort(0)
buffer.putShort(0)
buffer.putShort(0)
buffer.putInt(androidNsIndex)
buffer.putInt(nameAttrIndex)
buffer.putInt(nameValueIndex)
buffer.putShort(8)
buffer.put(0)
buffer.put(0x03)
buffer.putInt(nameValueIndex)
return Chunk(CHUNK_START_ELEMENT, 0, chunkSize, buffer.array())
}
private fun buildEndElement(androidNsIndex: Int, elementNameIndex: Int): Chunk {
val headerSize = 16
val chunkSize = 24
val buffer = ByteBuffer.allocate(chunkSize).order(ByteOrder.LITTLE_ENDIAN)
buffer.putShort(CHUNK_END_ELEMENT.toShort())
buffer.putShort(headerSize.toShort())
buffer.putInt(chunkSize)
buffer.putInt(0)
buffer.putInt(-1)
buffer.putInt(-1)
buffer.putInt(elementNameIndex)
return Chunk(CHUNK_END_ELEMENT, 0, chunkSize, buffer.array())
}
private fun findApplicationEndIndex(parsed: ParsedAxml): Int {
val applicationStrIndex = parsed.stringPool.strings.indexOf("application")
if (applicationStrIndex < 0) return -1
for (i in parsed.chunks.indices) {
val chunk = parsed.chunks[i]
if (chunk.type == CHUNK_END_ELEMENT) {
val buffer = ByteBuffer.wrap(chunk.data).order(ByteOrder.LITTLE_ENDIAN)
buffer.position(16)
buffer.int
val name = buffer.int
if (name == applicationStrIndex) {
return i
}
}
}
return -1
}
private fun findMatchingEndElementIndex(parsed: ParsedAxml, startIndex: Int): Int {
val startChunk = parsed.chunks.getOrNull(startIndex) ?: return -1
if (startChunk.type != CHUNK_START_ELEMENT) return -1
val elementNameIndex = readElementNameIndex(startChunk)
if (elementNameIndex < 0) return -1
var depth = 0
for (i in startIndex until parsed.chunks.size) {
val chunk = parsed.chunks[i]
if (readElementNameIndex(chunk) != elementNameIndex) continue
when (chunk.type) {
CHUNK_START_ELEMENT -> depth++
CHUNK_END_ELEMENT -> {
depth--
if (depth == 0) return i
}
}
}
return -1
}
private fun readElementNameIndex(chunk: Chunk): Int {
if (chunk.data.size < 24) return -1
val buffer = ByteBuffer.wrap(chunk.data).order(ByteOrder.LITTLE_ENDIAN)
return buffer.getInt(20)
}
private fun readStringAttribute(parsed: ParsedAxml, chunk: Chunk, attrIndex: Int): String? {
if (chunk.type != CHUNK_START_ELEMENT || chunk.data.size < 36) return null
val buffer = ByteBuffer.wrap(chunk.data).order(ByteOrder.LITTLE_ENDIAN)
val attrStart = buffer.getShort(24).toInt() and 0xFFFF
val attrSize = buffer.getShort(26).toInt() and 0xFFFF
val attrCount = buffer.getShort(28).toInt() and 0xFFFF
if (attrSize == 0 || attrCount == 0) return null
for (i in 0 until attrCount) {
val attrOffset = 16 + attrStart + i * attrSize
if (attrOffset + 20 > chunk.data.size) break
val attrName = buffer.getInt(attrOffset + 4)
if (attrName != attrIndex) continue
val attrValueType = buffer.get(attrOffset + 15).toInt() and 0xFF
if (attrValueType != 0x03) return null
val attrValueData = buffer.getInt(attrOffset + 16)
return parsed.stringPool.strings.getOrNull(attrValueData)
}
return null
}
private fun getOrAddString(pool: StringPool, str: String): Int {
val index = pool.strings.indexOf(str)
if (index >= 0) return index
pool.strings.add(str)
return pool.strings.size - 1
}
private fun parseAxml(data: ByteArray): ParsedAxml? {
if (data.size < 8) return null
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
val fileType = buffer.short.toInt() and 0xFFFF
val fileHeaderSize = buffer.short.toInt() and 0xFFFF
val fileSize = buffer.int
if (fileType != CHUNK_AXML_FILE) return null
val chunks = mutableListOf<Chunk>()
var stringPool: StringPool? = null
var resourceMap: IntArray? = null
var offset = fileHeaderSize
while (offset + 8 <= data.size) {
buffer.position(offset)
val chunkType = buffer.short.toInt() and 0xFFFF
val chunkHeaderSize = buffer.short.toInt() and 0xFFFF
val chunkSize = buffer.int
if (chunkSize <= 0 || offset + chunkSize > data.size) break
when (chunkType) {
CHUNK_STRING_POOL -> stringPool = parseStringPool(data, offset)
CHUNK_RESOURCE_MAP -> resourceMap = parseResourceMap(data, offset, chunkSize)
else -> chunks.add(Chunk(chunkType, offset, chunkSize, data.copyOfRange(offset, offset + chunkSize)))
}
offset += chunkSize
}
if (stringPool == null) return null
return ParsedAxml(fileHeaderSize, stringPool, resourceMap, chunks)
}
private fun parseStringPool(data: ByteArray, offset: Int): StringPool {
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
buffer.position(offset)
buffer.short
val headerSize = buffer.short.toInt() and 0xFFFF
val chunkSize = buffer.int
val stringCount = buffer.int
val styleCount = buffer.int
val flags = buffer.int
val stringsStart = buffer.int
val stylesStart = buffer.int
val isUtf8 = (flags and 0x100) != 0
val stringOffsets = IntArray(stringCount) { buffer.int }
val styleOffsets = IntArray(styleCount) { buffer.int }
val stringsDataStart = offset + stringsStart
val strings = mutableListOf<String>()
for (i in 0 until stringCount) {
val strOffset = stringsDataStart + stringOffsets[i]
val str = if (isUtf8) readUtf8String(data, strOffset) else readUtf16String(data, strOffset)
strings.add(str)
}
val stylesData = if (styleCount > 0 && stylesStart > 0) {
data.copyOfRange(offset + stylesStart, offset + chunkSize)
} else null
val originalStringsDataSize = if (stylesStart > 0) stylesStart - stringsStart else chunkSize - stringsStart
return StringPool(flags, isUtf8, strings.toMutableList(), styleOffsets, stylesData, originalStringsDataSize)
}
private fun parseResourceMap(data: ByteArray, offset: Int, size: Int): IntArray {
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
buffer.position(offset + 8)
val count = (size - 8) / 4
return IntArray(count) { buffer.int }
}
private fun findRelativeClassNames(parsed: ParsedAxml, originalPackage: String): List<ClassNameExpansion> {
val expansions = mutableListOf<ClassNameExpansion>()
val resourceMap = parsed.resourceMap ?: return expansions
val nameAttrIndex = resourceMap.indexOf(ATTR_NAME)
if (nameAttrIndex < 0) return expansions
for ((chunkIdx, chunk) in parsed.chunks.withIndex()) {
if (chunk.type != CHUNK_START_ELEMENT) continue
val buffer = ByteBuffer.wrap(chunk.data).order(ByteOrder.LITTLE_ENDIAN)
buffer.position(16)
buffer.int
val elementName = buffer.int
val attrStart = buffer.short.toInt() and 0xFFFF
val attrSize = buffer.short.toInt() and 0xFFFF
val attrCount = buffer.short.toInt() and 0xFFFF
if (attrSize == 0 || attrCount == 0) continue
val elementNameStr = parsed.stringPool.strings.getOrNull(elementName) ?: continue
if (elementNameStr !in listOf("activity", "service", "receiver", "provider", "application", "activity-alias")) continue
for (i in 0 until attrCount) {
val attrOffset = 36 + i * attrSize
if (attrOffset + 20 > chunk.data.size) break
buffer.position(attrOffset)
buffer.int
val attrName = buffer.int
buffer.int
buffer.short
buffer.get()
val attrValueType = buffer.get().toInt() and 0xFF
val attrValueData = buffer.int
if (attrName != nameAttrIndex || attrValueType != 3) continue
val stringValue = parsed.stringPool.strings.getOrNull(attrValueData) ?: continue
if (stringValue.startsWith(".") || (!stringValue.contains(".") && stringValue.isNotEmpty())) {
val absoluteName = if (stringValue.startsWith(".")) {
originalPackage + stringValue
} else {
"$originalPackage.$stringValue"
}
expansions.add(ClassNameExpansion(chunkIdx, i, attrOffset, attrValueData, stringValue, absoluteName))
}
}
}
return expansions
}
private fun expandClassNames(parsed: ParsedAxml, expansions: List<ClassNameExpansion>) {
for (expansion in expansions) {
var newIndex = parsed.stringPool.strings.indexOf(expansion.expandedValue)
if (newIndex < 0) {
newIndex = parsed.stringPool.strings.size
parsed.stringPool.strings.add(expansion.expandedValue)
}
val chunk = parsed.chunks[expansion.chunkIndex]
val buffer = ByteBuffer.wrap(chunk.data).order(ByteOrder.LITTLE_ENDIAN)
buffer.position(expansion.attrOffset + 8)
buffer.putInt(newIndex)
buffer.position(expansion.attrOffset + 16)
buffer.putInt(newIndex)
}
}
private fun replacePackageString(parsed: ParsedAxml, oldPackage: String, newPackage: String) {
for (i in parsed.stringPool.strings.indices) {
val str = parsed.stringPool.strings[i]
when {
str == oldPackage -> parsed.stringPool.strings[i] = newPackage
str.startsWith("$oldPackage.") -> {
val suffix = str.substring(oldPackage.length + 1)
if (!isLikelyClassName(suffix)) {
parsed.stringPool.strings[i] = newPackage + str.substring(oldPackage.length)
}
}
}
}
}
private fun isLikelyClassName(suffix: String): Boolean {
val lastDotIndex = suffix.lastIndexOf('.')
val className = if (lastDotIndex >= 0) suffix.substring(lastDotIndex + 1) else suffix
if (className.isNotEmpty() && className[0].isUpperCase()) {
val componentSuffixes = listOf("Activity", "Service", "Provider", "Receiver", "Application", "Fragment", "Adapter", "View", "Manager", "Helper", "Listener", "Callback")
return componentSuffixes.any { className.endsWith(it) } || className.matches(Regex("^[A-Z][a-zA-Z0-9]*$"))
}
return false
}
private fun rebuildAxml(parsed: ParsedAxml): ByteArray {
val output = java.io.ByteArrayOutputStream()
val stringPoolData = rebuildStringPool(parsed.stringPool)
val resourceMapData = parsed.resourceMap?.let { rebuildResourceMap(it) } ?: ByteArray(0)
val chunksData = java.io.ByteArrayOutputStream()
for (chunk in parsed.chunks) chunksData.write(chunk.data)
val totalSize = parsed.fileHeaderSize + stringPoolData.size + resourceMapData.size + chunksData.size()
val header = ByteBuffer.allocate(parsed.fileHeaderSize).order(ByteOrder.LITTLE_ENDIAN)
header.putShort(CHUNK_AXML_FILE.toShort())
header.putShort(parsed.fileHeaderSize.toShort())
header.putInt(totalSize)
output.write(header.array())
output.write(stringPoolData)
output.write(resourceMapData)
chunksData.writeTo(output)
return output.toByteArray()
}
private fun rebuildStringPool(pool: StringPool): ByteArray {
val isUtf8 = pool.isUtf8
val stringCount = pool.strings.size
val styleCount = pool.styleOffsets.size
val stringsBuffer = java.io.ByteArrayOutputStream()
val stringOffsets = IntArray(stringCount)
for (i in 0 until stringCount) {
stringOffsets[i] = stringsBuffer.size()
if (isUtf8) writeUtf8String(stringsBuffer, pool.strings[i])
else writeUtf16String(stringsBuffer, pool.strings[i])
}
while (stringsBuffer.size() % 4 != 0) stringsBuffer.write(0)
val stringsData = stringsBuffer.toByteArray()
val stringsDataSizeDelta = stringsData.size - pool.originalStringsDataSize
val headerSize = 28
val offsetsSize = (stringCount + styleCount) * 4
val stringsStart = headerSize + offsetsSize
val stylesStart = if (styleCount > 0 && pool.stylesData != null) stringsStart + stringsData.size else 0
val stylesDataSize = pool.stylesData?.size ?: 0
val chunkSize = stringsStart + stringsData.size + stylesDataSize
val result = ByteBuffer.allocate(chunkSize).order(ByteOrder.LITTLE_ENDIAN)
result.putShort(CHUNK_STRING_POOL.toShort())
result.putShort(headerSize.toShort())
result.putInt(chunkSize)
result.putInt(stringCount)
result.putInt(styleCount)
result.putInt(pool.flags and 0x01.inv())
result.putInt(stringsStart)
result.putInt(stylesStart)
for (offset in stringOffsets) result.putInt(offset)
for (offset in pool.styleOffsets) result.putInt(offset + stringsDataSizeDelta)
result.put(stringsData)
pool.stylesData?.let { result.put(it) }
return result.array()
}
private fun rebuildResourceMap(resourceMap: IntArray): ByteArray {
val size = 8 + resourceMap.size * 4
val buffer = ByteBuffer.allocate(size).order(ByteOrder.LITTLE_ENDIAN)
buffer.putShort(CHUNK_RESOURCE_MAP.toShort())
buffer.putShort(8.toShort())
buffer.putInt(size)
for (id in resourceMap) buffer.putInt(id)
return buffer.array()
}
private fun readUtf8String(data: ByteArray, offset: Int): String {
if (offset >= data.size) return ""
var o = offset
var charLen = data[o].toInt() and 0x7F
if (data[o].toInt() and 0x80 != 0) {
if (o + 1 >= data.size) return ""
charLen = ((data[o].toInt() and 0x7F) shl 8) or (data[o + 1].toInt() and 0xFF)
o += 2
} else o += 1
var byteLen = data[o].toInt() and 0x7F
if (data[o].toInt() and 0x80 != 0) {
if (o + 1 >= data.size) return ""
byteLen = ((data[o].toInt() and 0x7F) shl 8) or (data[o + 1].toInt() and 0xFF)
o += 2
} else o += 1
if (o + byteLen > data.size) return ""
return String(data, o, byteLen, Charsets.UTF_8)
}
private fun readUtf16String(data: ByteArray, offset: Int): String {
if (offset + 2 > data.size) return ""
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
buffer.position(offset)
var length = buffer.short.toInt() and 0xFFFF
if (length and 0x8000 != 0) {
if (offset + 4 > data.size) return ""
length = ((length and 0x7FFF) shl 16) or (buffer.short.toInt() and 0xFFFF)
}
val byteLen = length * 2
if (buffer.position() + byteLen > data.size) return ""
val strBytes = ByteArray(byteLen)
buffer.get(strBytes)
return String(strBytes, Charsets.UTF_16LE)
}
private fun writeUtf8String(output: java.io.ByteArrayOutputStream, str: String) {
val bytes = str.toByteArray(Charsets.UTF_8)
val charLen = str.length
val byteLen = bytes.size
if (charLen > 0x7F) { output.write(0x80 or ((charLen shr 8) and 0x7F)); output.write(charLen and 0xFF) }
else output.write(charLen)
if (byteLen > 0x7F) { output.write(0x80 or ((byteLen shr 8) and 0x7F)); output.write(byteLen and 0xFF) }
else output.write(byteLen)
output.write(bytes)
output.write(0)
}
private fun writeUtf16String(output: java.io.ByteArrayOutputStream, str: String) {
val length = str.length
if (length > 0x7FFF) {
output.write(0x80 or ((length shr 24) and 0x7F))
output.write((length shr 16) and 0xFF)
output.write((length shr 8) and 0xFF)
output.write(length and 0xFF)
} else {
output.write(length and 0xFF)
output.write((length shr 8) and 0xFF)
}
output.write(str.toByteArray(Charsets.UTF_16LE))
output.write(0)
output.write(0)
}
private data class ParsedAxml(
val fileHeaderSize: Int,
val stringPool: StringPool,
var resourceMap: IntArray?,
val chunks: MutableList<Chunk>
)
private data class StringPool(
val flags: Int,
val isUtf8: Boolean,
val strings: MutableList<String>,
val styleOffsets: IntArray,
val stylesData: ByteArray?,
val originalStringsDataSize: Int
)
private data class Chunk(val type: Int, val offset: Int, val size: Int, val data: ByteArray)
private data class ClassNameExpansion(
val chunkIndex: Int,
val attrIndex: Int,
val attrOffset: Int,
val originalStringIndex: Int,
val originalValue: String,
val expandedValue: String
)
}
+25
View File
@@ -0,0 +1,25 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.webtoapp.clone"
compileSdk = 36
defaultConfig {
minSdk = 23
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
}
+13
View File
@@ -0,0 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="com.webtoapp.clone.CloneLauncherActivity"
android:exported="true"
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,534 @@
package com.webtoapp.clone
import android.app.Activity
import android.content.Intent
import android.content.pm.ActivityInfo
import android.graphics.Color
import android.media.MediaPlayer
import android.net.Uri
import android.app.AlertDialog
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.view.WindowManager
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.util.UUID
class CloneLauncherActivity : Activity() {
companion object {
private const val CONFIG_ASSET = "clone_config.json"
private const val SPLASH_IMAGE_ASSET = "splash_media.png"
private const val SPLASH_VIDEO_ASSET = "splash_media.mp4"
private const val PREFS_NAME = "clone_activation"
}
private val handler = Handler(Looper.getMainLooper())
private var config: CloneConfig? = null
private var mediaPlayer: MediaPlayer? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
config = loadConfig()
if (config == null) {
launchTargetAndFinish()
return
}
val cfg = config!!
if (cfg.splashOrientation == "LANDSCAPE") {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
val activationAppId = -(kotlin.math.abs(cfg.targetPackage.hashCode().toLong()) + 100L)
if (cfg.activationEnabled) {
handleActivation(cfg, activationAppId)
} else {
proceedAfterActivation(cfg)
}
}
private fun loadConfig(): CloneConfig? {
return try {
val json = assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() }
val obj = JSONObject(json)
val dialog = obj.optJSONObject("activationDialog")
CloneConfig(
targetPackage = obj.optString("targetPackage", ""),
splashEnabled = obj.optBoolean("splashEnabled", false),
splashType = obj.optString("splashType", "IMAGE"),
splashDuration = obj.optInt("splashDuration", 3),
splashClickToSkip = obj.optBoolean("splashClickToSkip", true),
splashOrientation = obj.optString("splashOrientation", "PORTRAIT"),
splashFillScreen = obj.optBoolean("splashFillScreen", true),
splashEnableAudio = obj.optBoolean("splashEnableAudio", false),
splashVideoStartMs = obj.optLong("splashVideoStartMs", 0),
splashVideoEndMs = obj.optLong("splashVideoEndMs", 5000),
activationEnabled = obj.optBoolean("activationEnabled", false),
activationCodes = toStringList(obj.optJSONArray("activationCodes")),
activationRequireEveryTime = obj.optBoolean("activationRequireEveryTime", false),
activationDialogTitle = dialog?.optString("title", "") ?: "",
activationDialogSubtitle = dialog?.optString("subtitle", "") ?: "",
activationDialogInputLabel = dialog?.optString("inputLabel", "") ?: "",
activationDialogButtonText = dialog?.optString("buttonText", "") ?: "",
remoteActivationEnabled = obj.optBoolean("remoteActivationEnabled", false),
remoteVerifyUrl = obj.optString("remoteVerifyUrl", ""),
remoteOfflinePolicy = obj.optString("remoteOfflinePolicy", "ALLOW_CACHED"),
announcementEnabled = obj.optBoolean("announcementEnabled", false),
announcementTitle = obj.optString("announcementTitle", ""),
announcementContent = obj.optString("announcementContent", ""),
announcementContentIsHtml = obj.optBoolean("announcementContentIsHtml", false),
announcementLinkUrl = obj.optString("announcementLinkUrl", ""),
announcementLinkText = obj.optString("announcementLinkText", ""),
originalLauncherActivity = obj.optString("originalLauncherActivity", "")
)
} catch (e: Exception) {
null
}
}
private fun toStringList(arr: org.json.JSONArray?): List<String> {
if (arr == null) return emptyList()
return (0 until arr.length()).map { arr.optString(it, "") }
}
private fun handleActivation(cfg: CloneConfig, appId: Long) {
if (cfg.activationRequireEveryTime) {
clearActivation(appId)
showActivationDialog(cfg, appId)
return
}
if (cfg.remoteActivationEnabled) {
Thread {
val activated = isActivated(appId) && checkRemoteActivation(cfg)
handler.post {
if (activated) {
proceedAfterActivation(cfg)
} else {
showActivationDialog(cfg, appId)
}
}
}.start()
} else {
if (isActivated(appId)) {
proceedAfterActivation(cfg)
} else {
showActivationDialog(cfg, appId)
}
}
}
private fun showActivationDialog(cfg: CloneConfig, appId: Long) {
val title = cfg.activationDialogTitle.ifBlank { "Activation Required" }
val subtitle = cfg.activationDialogSubtitle.ifBlank { "Enter activation code to continue" }
val inputLabel = cfg.activationDialogInputLabel.ifBlank { "Activation Code" }
val buttonText = cfg.activationDialogButtonText.ifBlank { "Activate" }
val input = EditText(this).apply {
hint = inputLabel
setSingleLine(true)
val pad = dp(16)
setPadding(pad, pad, pad, pad)
}
val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
val pad = dp(20)
setPadding(pad, dp(8), pad, dp(8))
addView(TextView(this@CloneLauncherActivity).apply {
text = subtitle
textSize = 14f
})
addView(input)
}
val scrollView = ScrollView(this).apply { addView(container) }
val dialog = AlertDialog.Builder(this)
.setTitle(title)
.setView(scrollView)
.setCancelable(false)
.setPositiveButton(buttonText) { _, _ -> }
.setNegativeButton("Cancel") { _, _ -> finish() }
.create()
dialog.setCanceledOnTouchOutside(false)
dialog.show()
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val code = input.text.toString().trim()
if (code.isBlank()) {
input.error = "Please enter activation code"
return@setOnClickListener
}
Thread {
val result = verifyCode(cfg, code)
handler.post {
if (result) {
saveActivation(appId)
dialog.dismiss()
proceedAfterActivation(cfg)
} else {
input.error = "Invalid activation code"
}
}
}.start()
}
}
private fun verifyCode(cfg: CloneConfig, inputCode: String): Boolean {
if (cfg.remoteActivationEnabled) {
return verifyRemoteCode(cfg, inputCode)
}
val normalized = normalizeCode(inputCode)
return cfg.activationCodes.any { validCode ->
val normalizedValid = normalizeCode(validCode)
constantTimeEquals(normalized, normalizedValid) ||
constantTimeEquals(sha256(normalized), normalizedValid)
}
}
private fun verifyRemoteCode(cfg: CloneConfig, inputCode: String): 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 response = conn.inputStream.bufferedReader().use { it.readText() }
val result = JSONObject(response)
result.optBoolean("success", false)
} else {
cfg.remoteOfflinePolicy == "ALLOW"
}
} catch (e: Exception) {
cfg.remoteOfflinePolicy == "ALLOW" || cfg.remoteOfflinePolicy == "ALLOW_CACHED"
}
}
private fun checkRemoteActivation(cfg: CloneConfig): 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 response = conn.inputStream.bufferedReader().use { it.readText() }
val result = JSONObject(response)
result.optBoolean("success", false)
} else {
cfg.remoteOfflinePolicy == "ALLOW_CACHED"
}
} catch (e: Exception) {
cfg.remoteOfflinePolicy == "ALLOW_CACHED" || cfg.remoteOfflinePolicy == "ALLOW"
}
}
private fun proceedAfterActivation(cfg: CloneConfig) {
if (cfg.announcementEnabled && cfg.announcementTitle.isNotBlank()) {
showAnnouncement(cfg)
} else {
showSplash(cfg)
}
}
private fun showAnnouncement(cfg: CloneConfig) {
val content = cfg.announcementContent
val title = cfg.announcementTitle
val textView = TextView(this).apply {
text = if (cfg.announcementContentIsHtml) {
@Suppress("DEPRECATION")
android.text.Html.fromHtml(content)
} else {
content
}
textSize = 14f
val pad = dp(20)
setPadding(pad, pad, pad, pad)
}
val scrollView = ScrollView(this).apply { addView(textView) }
val builder = AlertDialog.Builder(this)
.setTitle(title)
.setView(scrollView)
.setCancelable(false)
if (cfg.announcementLinkText.isNotBlank() && cfg.announcementLinkUrl.isNotBlank()) {
builder.setPositiveButton(cfg.announcementLinkText) { _, _ ->
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(cfg.announcementLinkUrl)))
} catch (e: Exception) { }
showSplash(cfg)
}
builder.setNegativeButton("Close") { _, _ -> showSplash(cfg) }
} else {
builder.setPositiveButton("OK") { _, _ -> showSplash(cfg) }
}
builder.create().show()
}
private fun showSplash(cfg: CloneConfig) {
if (!cfg.splashEnabled) {
launchTargetAndFinish()
return
}
val splashFile = extractSplashMedia(cfg.splashType) ?: run {
launchTargetAndFinish()
return
}
val rootView = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
if (cfg.splashType == "IMAGE") {
val imageView = ImageView(this).apply {
scaleType = if (cfg.splashFillScreen) ImageView.ScaleType.CENTER_CROP else ImageView.ScaleType.FIT_CENTER
setImageURI(Uri.fromFile(splashFile))
}
rootView.addView(imageView, FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
))
} else {
val surfaceView = SurfaceView(this)
rootView.addView(surfaceView, FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
))
surfaceView.holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
try {
mediaPlayer = MediaPlayer().apply {
setDataSource(splashFile.absolutePath)
setSurface(holder.surface)
val volume = if (cfg.splashEnableAudio) 1f else 0f
setVolume(volume, volume)
isLooping = false
setOnPreparedListener { mp ->
mp.seekTo(cfg.splashVideoStartMs.toInt())
mp.start()
if (cfg.splashVideoEndMs > cfg.splashVideoStartMs) {
handler.postDelayed({
if (mp.isPlaying) mp.pause()
launchTargetAndFinish()
}, cfg.splashVideoEndMs - cfg.splashVideoStartMs)
}
}
setOnCompletionListener { launchTargetAndFinish() }
prepareAsync()
}
} catch (e: Exception) {
launchTargetAndFinish()
}
}
override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {}
override fun surfaceDestroyed(h: SurfaceHolder) {
mediaPlayer?.release()
mediaPlayer = null
}
})
}
val countdownText = TextView(this).apply {
setTextColor(Color.WHITE)
textSize = 12f
setPadding(dp(12), dp(6), dp(12), dp(6))
setBackgroundColor(0x99000000.toInt())
visibility = View.GONE
}
rootView.addView(countdownText, FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
android.view.Gravity.TOP or android.view.Gravity.END
).apply { setMargins(0, dp(16), dp(16), 0) })
if (cfg.splashClickToSkip) {
rootView.setOnClickListener { launchTargetAndFinish() }
}
setContentView(rootView)
if (cfg.splashType == "IMAGE") {
var countdown = cfg.splashDuration
countdownText.visibility = View.VISIBLE
countdownText.text = "${countdown}s"
handler.post(object : Runnable {
override fun run() {
countdown--
if (countdown > 0) {
countdownText.text = "${countdown}s"
handler.postDelayed(this, 1000)
} else {
launchTargetAndFinish()
}
}
})
}
}
private fun extractSplashMedia(splashType: String): File? {
val assetName = if (splashType == "VIDEO") SPLASH_VIDEO_ASSET else SPLASH_IMAGE_ASSET
return try {
val cacheFile = File(cacheDir, assetName)
assets.open(assetName).use { input ->
FileOutputStream(cacheFile).use { output -> input.copyTo(output) }
}
cacheFile
} catch (e: Exception) {
null
}
}
private fun launchTargetAndFinish() {
try {
val cfg = config
var launched = false
if (cfg != null && cfg.originalLauncherActivity.isNotBlank()) {
try {
val intent = Intent().apply {
setClassName(packageName, cfg.originalLauncherActivity)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
startActivity(intent)
launched = true
} catch (e: Exception) {
android.util.Log.d("CloneLauncher", "Direct launch failed: ${e.message}, trying getLaunchIntentForPackage")
}
}
if (!launched) {
val launchIntent = packageManager.getLaunchIntentForPackage(cfg?.targetPackage ?: "")
if (launchIntent != null) {
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(launchIntent)
}
}
} catch (e: Exception) { }
finishAndRemoveTask()
}
override fun onDestroy() {
super.onDestroy()
mediaPlayer?.release()
mediaPlayer = null
handler.removeCallbacksAndMessages(null)
}
private fun dp(dp: Int): Int = (dp * resources.displayMetrics.density).toInt()
private fun isActivated(appId: Long): Boolean {
return getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
.getBoolean("activated_$appId", false)
}
private fun saveActivation(appId: Long) {
getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
.edit()
.putBoolean("activated_$appId", true)
.putLong("activated_time_$appId", System.currentTimeMillis())
.apply()
}
private fun clearActivation(appId: Long) {
getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
.edit()
.remove("activated_$appId")
.remove("activated_time_$appId")
.apply()
}
private fun generateDeviceId(): String {
val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
var id = prefs.getString("device_id", null)
if (id == null) {
id = UUID.randomUUID().toString()
prefs.edit().putString("device_id", id).apply()
}
return id
}
private fun normalizeCode(code: String): String {
return code.replace("-", "").replace(" ", "").uppercase().trim()
}
private fun sha256(input: String): String {
val bytes = MessageDigest.getInstance("SHA-256")
.digest((input + "WebToApp_Salt_2024").toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
private fun constantTimeEquals(a: String, b: String): Boolean {
if (a.length != b.length) return false
var result = 0
for (i in a.indices) {
result = result or (a[i].code xor b[i].code)
}
return result == 0
}
}
data class CloneConfig(
val targetPackage: String = "",
val splashEnabled: Boolean = false,
val splashType: String = "IMAGE",
val splashDuration: Int = 3,
val splashClickToSkip: Boolean = true,
val splashOrientation: String = "PORTRAIT",
val splashFillScreen: Boolean = true,
val splashEnableAudio: Boolean = false,
val splashVideoStartMs: Long = 0,
val splashVideoEndMs: Long = 5000,
val activationEnabled: Boolean = false,
val activationCodes: List<String> = emptyList(),
val activationRequireEveryTime: Boolean = false,
val activationDialogTitle: String = "",
val activationDialogSubtitle: String = "",
val activationDialogInputLabel: String = "",
val activationDialogButtonText: String = "",
val remoteActivationEnabled: Boolean = false,
val remoteVerifyUrl: String = "",
val remoteOfflinePolicy: String = "ALLOW_CACHED",
val announcementEnabled: Boolean = false,
val announcementTitle: String = "",
val announcementContent: String = "",
val announcementContentIsHtml: Boolean = false,
val announcementLinkUrl: String = "",
val announcementLinkText: String = "",
val originalLauncherActivity: String = ""
)
+1
View File
@@ -19,3 +19,4 @@ dependencyResolutionManagement {
rootProject.name = "WebToApp"
include(":app")
include(":shell")
include(":clone-host")