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:
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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 = ""
|
||||
)
|
||||
@@ -19,3 +19,4 @@ dependencyResolutionManagement {
|
||||
rootProject.name = "WebToApp"
|
||||
include(":app")
|
||||
include(":shell")
|
||||
include(":clone-host")
|
||||
|
||||
Reference in New Issue
Block a user