340 lines
11 KiB
Kotlin
340 lines
11 KiB
Kotlin
import java.io.FileInputStream
|
|
import java.nio.file.Files
|
|
import java.nio.file.StandardCopyOption
|
|
import java.security.MessageDigest
|
|
import java.util.Properties
|
|
import java.util.UUID
|
|
|
|
fun verifySha256(file: File, expected: String, identity: String) {
|
|
val digest = MessageDigest.getInstance("SHA-256")
|
|
file.inputStream().buffered().use { input ->
|
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
|
while (true) {
|
|
val count = input.read(buffer)
|
|
if (count < 0) break
|
|
digest.update(buffer, 0, count)
|
|
}
|
|
}
|
|
val actual = digest.digest().joinToString("") {
|
|
(it.toInt() and 0xff).toString(16).padStart(2, '0')
|
|
}
|
|
if (actual != expected) {
|
|
throw GradleException("SHA-256 mismatch for $identity: expected $expected, got $actual")
|
|
}
|
|
}
|
|
|
|
fun promoteDirectory(staging: File, destination: File) {
|
|
val backup = File(destination.parentFile, "${destination.name}.backup-${UUID.randomUUID()}")
|
|
val hadDestination = destination.exists()
|
|
try {
|
|
if (hadDestination) {
|
|
Files.move(destination.toPath(), backup.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
|
}
|
|
try {
|
|
Files.move(staging.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
|
} catch (promotionFailure: Exception) {
|
|
if (hadDestination && backup.exists()) {
|
|
try {
|
|
Files.move(backup.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
|
} catch (restoreFailure: Exception) {
|
|
promotionFailure.addSuppressed(restoreFailure)
|
|
}
|
|
}
|
|
throw promotionFailure
|
|
}
|
|
if (hadDestination && backup.exists() && !backup.deleteRecursively()) {
|
|
throw GradleException("Failed to remove obsolete native artifact backup at ${backup.absolutePath}")
|
|
}
|
|
} finally {
|
|
staging.deleteRecursively()
|
|
}
|
|
}
|
|
|
|
plugins {
|
|
id("com.android.application")
|
|
id("kotlin-android")
|
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
|
id("dev.flutter.flutter-gradle-plugin")
|
|
}
|
|
|
|
val mpvVersion = "v1.0.7"
|
|
val mpvSha256 = "d55d440e587b2a9ffb91874d93069460a987be05fe72af8394849983f0df2d7a"
|
|
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
|
val mpvAar = "libmpv-release.aar"
|
|
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
|
|
|
val downloadLibmpv by tasks.registering {
|
|
val aar = File(mpvDir, mpvAar)
|
|
val manifest = File(mpvDir, ".manifest")
|
|
inputs.property("version", mpvVersion)
|
|
inputs.property("sourceUrl", mpvUrl)
|
|
inputs.property("sha256", mpvSha256)
|
|
outputs.files(aar, manifest)
|
|
doLast {
|
|
mpvDir.parentFile.mkdirs()
|
|
val staging = File(mpvDir.parentFile, "${mpvDir.name}.staging-${UUID.randomUUID()}")
|
|
try {
|
|
staging.mkdirs()
|
|
val stagedAar = File(staging, mpvAar)
|
|
try {
|
|
exec { commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath) }
|
|
} catch (error: Exception) {
|
|
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
|
|
}
|
|
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
|
|
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
|
|
promoteDirectory(staging, mpvDir)
|
|
} finally {
|
|
staging.deleteRecursively()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract libc++_shared.so from the libmpv AAR so the app source set can package
|
|
// it with top merge priority (see packaging { jniLibs } and sourceSets below).
|
|
val extractMpvLibcxx by tasks.registering {
|
|
dependsOn(downloadLibmpv)
|
|
val aar = File(mpvDir, mpvAar)
|
|
val outDir = File(mpvDir, "libcxx")
|
|
inputs.file(aar)
|
|
outputs.dir(outDir)
|
|
doLast {
|
|
outDir.deleteRecursively() // drop stale ABIs from a previous AAR version
|
|
outDir.mkdirs()
|
|
exec {
|
|
commandLine(
|
|
"unzip",
|
|
"-q",
|
|
"-o",
|
|
aar.absolutePath,
|
|
"jni/*/libc++_shared.so",
|
|
"-d",
|
|
outDir.absolutePath
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
val doviVersion = "2.3.1"
|
|
val doviDir = layout.buildDirectory.dir("libdovi").get().asFile
|
|
val doviArtifacts = mapOf(
|
|
"arm64-v8a" to Pair(
|
|
"aarch64-linux-android",
|
|
"9d2983fc86f2f9e6da54c3c84ba8ea3a528690619f312ff4620198071b84e9ae"
|
|
),
|
|
"armeabi-v7a" to Pair(
|
|
"armv7-linux-androideabi",
|
|
"ed6fec8bf744e41c661b97f5fc4bf1197ebe9b09a140cbde369728e790ee3a68"
|
|
),
|
|
"x86" to Pair(
|
|
"i686-linux-android",
|
|
"50f0a5606e617dff8976b9e7930a23272f4804882a35a6f0f2b2f2d3f8ed7135"
|
|
),
|
|
"x86_64" to Pair(
|
|
"x86_64-linux-android",
|
|
"eba59678f89b792f5c6f802962e237542fe8328f6aa03a0a90ee77353dac3194"
|
|
)
|
|
)
|
|
val doviBaseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
|
|
|
|
val downloadLibdovi by tasks.registering {
|
|
val manifest = File(doviDir, ".manifest")
|
|
inputs.property("version", doviVersion)
|
|
inputs.property("baseUrl", doviBaseUrl)
|
|
doviArtifacts.forEach { (abi, artifact) ->
|
|
inputs.property("$abi.triple", artifact.first)
|
|
inputs.property("$abi.sha256", artifact.second)
|
|
inputs.property("$abi.sourceUrl", "$doviBaseUrl/libdovi-${artifact.first}.tar.gz")
|
|
}
|
|
outputs.files(doviArtifacts.keys.map { abi -> File(doviDir, "$abi/lib/libdovi.a") } + manifest)
|
|
doLast {
|
|
doviDir.parentFile.mkdirs()
|
|
val staging = File(doviDir.parentFile, "${doviDir.name}.staging-${UUID.randomUUID()}")
|
|
try {
|
|
staging.mkdirs()
|
|
val downloads = File(staging, ".downloads").apply { mkdirs() }
|
|
doviArtifacts.forEach { (abi, artifact) ->
|
|
val (triple, expectedSha256) = artifact
|
|
val archiveName = "libdovi-$triple.tar.gz"
|
|
val archive = File(downloads, archiveName)
|
|
val sourceUrl = "$doviBaseUrl/$archiveName"
|
|
try {
|
|
exec { commandLine("curl", "-sfL", sourceUrl, "-o", archive.absolutePath) }
|
|
} catch (error: Exception) {
|
|
throw GradleException("Failed to download $archiveName v$doviVersion", error)
|
|
}
|
|
verifySha256(archive, expectedSha256, "$archiveName v$doviVersion")
|
|
|
|
val outDir = File(staging, "$abi/lib").apply { mkdirs() }
|
|
try {
|
|
exec { commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath) }
|
|
} catch (error: Exception) {
|
|
throw GradleException("Failed to extract $archiveName", error)
|
|
}
|
|
if (!File(outDir, "libdovi.a").isFile) {
|
|
throw GradleException("$archiveName did not contain the expected libdovi.a")
|
|
}
|
|
}
|
|
if (!downloads.deleteRecursively()) {
|
|
throw GradleException("Failed to clean staged libdovi archives")
|
|
}
|
|
val manifestText = buildString {
|
|
append("version=$doviVersion\n")
|
|
doviArtifacts.forEach { (abi, artifact) ->
|
|
append("$abi=${artifact.first},${artifact.second}\n")
|
|
}
|
|
}
|
|
File(staging, ".manifest").writeText(manifestText)
|
|
promoteDirectory(staging, doviDir)
|
|
} finally {
|
|
staging.deleteRecursively()
|
|
}
|
|
}
|
|
}
|
|
|
|
android {
|
|
namespace = "com.edde746.plezy"
|
|
compileSdk = flutter.compileSdkVersion
|
|
ndkVersion = flutter.ndkVersion
|
|
|
|
compileOptions {
|
|
sourceCompatibility = JavaVersion.VERSION_11
|
|
targetCompatibility = JavaVersion.VERSION_11
|
|
}
|
|
|
|
kotlinOptions {
|
|
jvmTarget = JavaVersion.VERSION_11.toString()
|
|
}
|
|
|
|
defaultConfig {
|
|
applicationId = "com.edde746.plezy"
|
|
// You can update the following values to match your application needs.
|
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
|
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
|
|
targetSdk = flutter.targetSdkVersion
|
|
versionCode = flutter.versionCode
|
|
versionName = flutter.versionName
|
|
|
|
externalNativeBuild {
|
|
cmake {
|
|
arguments += listOf(
|
|
"-DDOVI_ENABLE_LIBDOVI=ON",
|
|
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}"
|
|
)
|
|
}
|
|
}
|
|
|
|
if (System.getenv("AMAZON") != null) {
|
|
versionCode = (flutter.versionCode ?: 0) + 3000
|
|
ndk {
|
|
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
|
|
}
|
|
}
|
|
}
|
|
|
|
externalNativeBuild {
|
|
cmake {
|
|
path = file("src/main/cpp/CMakeLists.txt")
|
|
}
|
|
}
|
|
|
|
signingConfigs {
|
|
create("release") {
|
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
|
if (keystorePropertiesFile.exists()) {
|
|
val keystoreProperties = Properties()
|
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
|
|
|
keyAlias = keystoreProperties["keyAlias"] as String
|
|
keyPassword = keystoreProperties["keyPassword"] as String
|
|
storeFile = file(keystoreProperties["storeFile"] as String)
|
|
storePassword = keystoreProperties["storePassword"] as String
|
|
}
|
|
}
|
|
}
|
|
|
|
buildTypes {
|
|
release {
|
|
// Only use release signing if key.properties exists (not in CI/CD)
|
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
|
if (keystorePropertiesFile.exists()) {
|
|
signingConfig = signingConfigs.getByName("release")
|
|
}
|
|
// If key.properties doesn't exist, it will use debug signing for CI builds
|
|
ndk {
|
|
debugSymbolLevel = "FULL"
|
|
}
|
|
}
|
|
}
|
|
|
|
packaging {
|
|
jniLibs {
|
|
// pickFirst only suppresses the duplicate libc++ merge error; the
|
|
// sourceSets rule below makes libmpv's newer runtime win for
|
|
// std::from_chars<float>, while older native consumers remain ABI-compatible.
|
|
pickFirsts.add("lib/*/libc++_shared.so")
|
|
}
|
|
}
|
|
|
|
sourceSets {
|
|
getByName("main") {
|
|
// PROJECT-scope jniLibs merge ahead of subprojects/AARs, so dependency
|
|
// order cannot accidentally select the older libc++ copy.
|
|
jniLibs.srcDir(File(mpvDir, "libcxx/jni"))
|
|
}
|
|
}
|
|
|
|
lint {
|
|
// Enforce the app-owned minSdk boundary without auditing upstream AndroidX.
|
|
checkDependencies = false
|
|
checkOnly += setOf("NewApi")
|
|
}
|
|
}
|
|
|
|
flutter {
|
|
source = "../.."
|
|
}
|
|
|
|
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
|
|
dependsOn(downloadLibdovi)
|
|
}
|
|
|
|
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
|
|
dependsOn(downloadLibmpv, extractMpvLibcxx)
|
|
}
|
|
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
|
|
// extracted libmpv libc++ directory present during input discovery.
|
|
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach {
|
|
dependsOn(extractMpvLibcxx)
|
|
}
|
|
|
|
dependencies {
|
|
implementation(files(File(mpvDir, mpvAar)))
|
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
|
|
|
// Android TV Watch Next integration
|
|
implementation("androidx.tvprovider:tvprovider:1.0.0")
|
|
|
|
// Media3 ExoPlayer for Android
|
|
implementation("androidx.media3:media3-exoplayer:1.9.2")
|
|
implementation("androidx.media3:media3-exoplayer-hls:1.9.2")
|
|
implementation("androidx.media3:media3-ui:1.9.2")
|
|
implementation("androidx.media3:media3-common:1.9.2")
|
|
|
|
// Cronet for HTTP/2 multiplexing + better connection management
|
|
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
|
|
implementation("org.chromium.net:cronet-embedded:143.7445.0")
|
|
|
|
// FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.)
|
|
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
|
|
|
|
// Keeping libass in-project lets its static core share the app's native
|
|
// packaging rules.
|
|
implementation(project(":libass"))
|
|
|
|
testImplementation("junit:junit:4.13.2")
|
|
// Real android.util.* implementations for tests exercising media3 classes
|
|
// (MatroskaExtractor uses SparseArray, which is a no-op stub on plain JVM)
|
|
testImplementation("org.robolectric:robolectric:4.15.1")
|
|
}
|