Files
XSnap/app/build.gradle.kts
2026-01-13 23:41:29 +05:30

356 lines
14 KiB
Kotlin

import com.android.build.gradle.internal.api.BaseVariantOutputImpl
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import com.android.build.api.artifact.MultipleArtifact
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import java.io.ByteArrayOutputStream
import java.security.MessageDigest
import java.security.KeyStore
import java.io.File
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.compose.compiler)
id("kotlin-parcelize")
id("com.google.devtools.ksp") version "2.2.20-2.0.3"
}
abstract class GenerateDexIntegrityAsset : org.gradle.api.DefaultTask() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val dexFiles: ConfigurableFileCollection
@get:Input
abstract val variantName: Property<String>
@get:Input
abstract val pinnedSha256: Property<String>
@get:org.gradle.api.tasks.InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val r8DexDir: DirectoryProperty
@get:org.gradle.api.tasks.InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val assetsDir: DirectoryProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun writeIntegrityAsset() {
val pinned = pinnedSha256.orNull?.trim().orEmpty()
if (pinned.isNotBlank()) {
val assetDir = outputDir.get().asFile.apply { mkdirs() }
assetDir.resolve("ps_integrity.bin").writeText(pinned.lowercase())
return
}
fun mapToApkEntry(file: java.io.File): String {
val segments = file.invariantSeparatorsPath.split("/")
val idx = segments.indexOfLast { it == "assets" }
return if (idx != -1 && idx + 1 < segments.size) {
("assets/" + segments.drop(idx + 1).joinToString("/"))
} else {
file.name
}
}
val digest = MessageDigest.getInstance("SHA-256")
val allFiles = mutableListOf<java.io.File>()
allFiles += r8DexDir.asFileTree.files.filter { it.extension == "dex" }
allFiles += assetsDir.get().asFile.walkTopDown().filter { it.isFile && it.extension == "dex" }.toList()
allFiles
.map { mapToApkEntry(it) to it }
.sortedBy { it.first }
.forEach { (_, file) ->
file.inputStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read <= 0) break
digest.update(buffer, 0, read)
}
}
}
val expectedHash = digest.digest().joinToString("") { "%02x".format(it) }
val assetDir = outputDir.get().asFile.apply { mkdirs() }
assetDir.resolve("ps_integrity.bin").writeText(expectedHash)
}
}
fun computeKeystoreCertSha256(storeFile: File, storePass: String, keyAlias: String, keyPass: String = storePass): String? {
if (!storeFile.exists()) return null
return runCatching {
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
storeFile.inputStream().use { ks.load(it, storePass.toCharArray()) }
val cert = ks.getCertificate(keyAlias) ?: return null
val digest = MessageDigest.getInstance("SHA-256")
digest.digest(cert.encoded).joinToString("") { "%02x".format(it) }
}.getOrNull()
}
android {
namespace = rootProject.ext["applicationId"].toString()
compileSdk = 36
ndkVersion = "28.2.13676358"
buildFeatures {
aidl = true
compose = true
buildConfig = true
}
signingConfigs {
create("release") {
storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
storePassword = providers.gradleProperty("PS_RELEASE_STORE_PASSWORD").orNull
keyAlias = providers.gradleProperty("PS_RELEASE_KEY_ALIAS").orNull
keyPassword = providers.gradleProperty("PS_RELEASE_KEY_PASSWORD").orNull
}
}
defaultConfig {
val autoCertSha = providers.provider {
val releaseStore = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
val releaseStorePass = providers.gradleProperty("PS_RELEASE_STORE_PASSWORD").orNull
val releaseKeyAlias = providers.gradleProperty("PS_RELEASE_KEY_ALIAS").orNull
val releaseKeyPass = providers.gradleProperty("PS_RELEASE_KEY_PASSWORD").orNull
if (!releaseStorePass.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank()) {
computeKeystoreCertSha256(
releaseStore,
storePass = releaseStorePass,
keyAlias = releaseKeyAlias,
keyPass = releaseKeyPass ?: releaseStorePass
)
} else {
computeKeystoreCertSha256(
File(System.getProperty("user.home"), ".android/debug.keystore"),
storePass = "android",
keyAlias = "androiddebugkey"
)
}.orEmpty()
}
val expectedCertSha256 = providers.gradleProperty("EXPECTED_CERT_SHA256")
.orElse(providers.gradleProperty("psExpectedCertSha256"))
.orElse(autoCertSha)
.orElse("")
applicationId = rootProject.ext["applicationId"].toString()
versionCode = rootProject.ext["appVersionCode"].toString().toInt()
versionName = rootProject.ext["appVersionName"].toString()
minSdk = 28
targetSdk = 36
multiDexEnabled = true
buildConfigField("String", "EXPECTED_CERT_SHA256", "\"${expectedCertSha256.get()}\"")
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles += file("proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
debug {
(properties["debug_flavor"] == null).also {
isDebuggable = !it
isMinifyEnabled = it
isShrinkResources = it
}
proguardFiles += file("proguard-rules.pro")
}
}
flavorDimensions += "abi"
productFlavors {
packaging {
jniLibs {
excludes += "**/*_neon.so"
}
resources {
excludes += "DebugProbesKt.bin"
excludes += "okhttp3/internal/publicsuffix/**"
excludes += "META-INF/*.version"
excludes += "META-INF/services/**"
excludes += "META-INF/*.kotlin_builtins"
excludes += "META-INF/*.kotlin_module"
}
}
create("core") {
dimension = "abi"
}
create("armv8") {
ndk {
abiFilters += "arm64-v8a"
}
dimension = "abi"
}
create("armv7") {
ndk {
abiFilters += "armeabi-v7a"
}
dimension = "abi"
}
create("all") {
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
}
dimension = "abi"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
androidComponents {
onVariants { variant ->
val flavorName = variant.flavorName
if (properties["debug_flavor"] == flavorName) {
// variant.makeDefault.set(true) // This is no longer supported
}
variant.outputs.forEach { output ->
val variantOutput = output as com.android.build.api.variant.impl.VariantOutputImpl
variantOutput.outputFileName.set(
when {
variant.name.startsWith("core") -> "core.apk"
else -> "purrfectsnap_${rootProject.ext["appVersionName"]}-${variant.name}.apk"
}
)
}
val integrityTaskName = "generate${variant.name.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }}DexIntegrity"
val capitalizedVariant = variant.name.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
val dexOutput = layout.buildDirectory.dir("intermediates/dex/${variant.name}")
val integrityTask = tasks.register(integrityTaskName, GenerateDexIntegrityAsset::class.java) {
tasks.findByName("minify${capitalizedVariant}WithR8")?.let { dependsOn(it) }
dexFiles.from(
fileTree(dexOutput) { include("**/*.dex") }
)
variantName.set(variant.name)
r8DexDir.set(layout.buildDirectory.dir("intermediates/dex/${variant.name}/minify${capitalizedVariant}WithR8"))
assetsDir.set(layout.projectDirectory.dir("src/main/assets"))
outputDir.set(layout.buildDirectory.dir("generated/integrity/${variant.name}/assets"))
pinnedSha256.set(providers.gradleProperty("psIntegrityPinnedSha256").orElse(""))
}
variant.sources.assets?.addGeneratedSourceDirectory(integrityTask, GenerateDexIntegrityAsset::outputDir)
}
}
dependencies {
fun fullImplementation(dependencyNotation: Any) {
compileOnly(dependencyNotation)
for (flavorName in listOf("armv8", "armv7", "all")) {
dependencies.add("${flavorName}Implementation", dependencyNotation)
}
}
implementation(project(":core"))
implementation(project(":common"))
implementation(project(":native"))
implementation(libs.androidx.documentfile)
implementation("androidx.browser:browser:1.8.0")
implementation(libs.gson)
implementation(libs.smart.exception.java)
implementation(files("libs/ffmpeg-kit-full-gpl-6.0-2.LTS.aar"))
implementation(libs.osmdroid.android)
implementation(libs.rhino)
implementation(libs.androidx.activity.ktx)
fullImplementation(platform(libs.androidx.compose.bom))
fullImplementation(libs.bcprov.jdk18on)
fullImplementation(libs.androidx.navigation.compose)
fullImplementation(libs.androidx.material.icons.core)
fullImplementation(libs.androidx.material.ripple)
fullImplementation(libs.androidx.material.icons.extended)
fullImplementation(libs.androidx.material3)
fullImplementation(libs.coil.compose)
fullImplementation(libs.coil.video)
fullImplementation(libs.colorpicker.compose)
fullImplementation(libs.androidx.ui.tooling.preview)
properties["debug_flavor"]?.let {
debugImplementation(libs.androidx.ui.tooling)
}
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("com.google.android.material:material:1.13.0")
implementation(libs.androidx.material3)
implementation(libs.androidx.work.runtime.ktx)
// Patching pipeline dependencies
implementation(files("libs/apkzlib.jar"))
implementation(files("libs/ManifestEditor-1.0.2.jar"))
implementation(libs.apksig)
implementation(libs.dexlib2)
implementation(libs.jsoup)
implementation("com.google.auto.value:auto-value-annotations:1.10.4")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
implementation("org.json:json:20240303")
implementation(libs.fetch) {
exclude(group = "androidx.room", module = "room-runtime")
}
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// --- COMPOSE: explicit modern UI/Foundation for widthIn/wrapContentWidth ----
fullImplementation(libs.foundation)
fullImplementation(libs.ui)
fullImplementation(libs.foundation.layout)
// Animated navigation + transitions (Accompanist)
fullImplementation(libs.accompanist.navigation.animation)
}
afterEvaluate {
properties["debug_flavor"]?.toString()
?.let { flavor -> tasks.findByName("install${flavor.replaceFirstChar { it.uppercase() }}Debug") }
?.doLast {
runCatching {
val packageName = properties["debug_package_name"]?.toString() ?: return@runCatching
val devicesProcess = ProcessBuilder("adb", "devices")
.redirectErrorStream(true)
.start()
val devices = devicesProcess.inputStream.bufferedReader().useLines { lines ->
lines.drop(1)
.mapNotNull { line ->
line.split("\t").firstOrNull()?.takeIf { it.isNotEmpty() }
}
.toList()
}
devicesProcess.waitFor()
runBlocking {
devices.forEach { device ->
launch {
ProcessBuilder("adb", "-s", device, "shell", "am", "force-stop", packageName)
.redirectErrorStream(true)
.start()
.apply { waitFor() }
delay(500)
ProcessBuilder("adb", "-s", device, "shell", "am", "start", packageName)
.redirectErrorStream(true)
.start()
.apply { waitFor() }
}
}
}
}
}
}
properties["debug_flavor"]?.let {
configurations.all {
exclude(group = "androidx.profileinstaller", "profileinstaller")
}
}