import java.util.Properties plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") id("com.google.dagger.hilt.android") id("com.google.devtools.ksp") id("org.jetbrains.kotlin.plugin.serialization") } val localProperties = Properties().apply { val file = rootProject.file("local.properties") if (file.exists()) { load(file.inputStream()) } } fun readBuildSecret(name: String): String { val fromGradle = (project.findProperty(name) as String?)?.trim().orEmpty() if (fromGradle.isNotEmpty()) return fromGradle val fromLocal = localProperties.getProperty(name)?.trim().orEmpty() if (fromLocal.isNotEmpty()) return fromLocal return System.getenv(name)?.trim().orEmpty() } android { namespace = "com.unspooled.app" compileSdk = 35 defaultConfig { applicationId = "com.unspooled.app" minSdk = 24 targetSdk = 35 versionCode = 1 versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" ksp { arg("room.schemaLocation", "$projectDir/schemas") } // Credentials surfaced via BuildConfig so secrets never live in source. // For local dev these read from -P gradle props or environment; in CI they // come from a secret manager. Empty values are valid in debug builds but // cause auth flows to short-circuit at runtime with a clear error. val tmdbApiKey: String = readBuildSecret("TMDB_API_KEY") val traktClientId: String = readBuildSecret("TRAKT_CLIENT_ID") .ifBlank { error("TRAKT_CLIENT_ID is required — set in local.properties or buildSecrets.properties") } val traktClientSecret: String = readBuildSecret("TRAKT_CLIENT_SECRET") .ifBlank { "" } val realDebridClientId: String = readBuildSecret("RD_CLIENT_ID") .ifBlank { readBuildSecret("REAL_DEBRID_CLIENT_ID") } .ifBlank { "" } val traktRedirectUri: String = readBuildSecret("TRAKT_REDIRECT_URI") .ifBlank { "urn:ietf:wg:oauth:2.0:oob" } val traktApiUrl: String = readBuildSecret("TRAKT_API_URL") .ifBlank { "https://api.trakt.tv/" } val premiumizeClientId: String = readBuildSecret("PREMIUMIZE_CLIENT_ID") val mdblistApiKey: String = readBuildSecret("MDBLIST_API_KEY") val anilistClientId: String = readBuildSecret("ANILIST_CLIENT_ID") // Both worker URLs default to the same Cloudflare Worker (unspooled-config-v3) // which handles config sessions, addon URL QR entry, and debrid auth sessions. // Override via buildSecrets.properties / local.properties if hosting separately. val addonConfigWorkerUrl: String = readBuildSecret("ADDON_CONFIG_WORKER_URL") .ifBlank { "https://unspooled-config-v3.tw24fr.workers.dev" } val addonAuthWorkerUrl: String = readBuildSecret("ADDON_AUTH_WORKER_URL") .ifBlank { "https://unspooled-config-v3.tw24fr.workers.dev" } // Remote addon registry for discovery, presets, and deprecation notices. // Uses the same Cloudflare Worker by default. val addonRegistryBaseUrl: String = readBuildSecret("ADDON_REGISTRY_BASE_URL") .ifBlank { "https://unspooled-config-v3.tw24fr.workers.dev" } val nexstreamScraperUrl: String = readBuildSecret("NEXSTREAM_SCRAPER_URL") .ifBlank { "https://debian-ai.tailf83ed5.ts.net" } buildConfigField("String", "TMDB_API_KEY", "\"${tmdbApiKey}\"") buildConfigField("String", "TMDB_API_KEY_FALLBACK", "\"${readBuildSecret("TMDB_API_KEY_FALLBACK")}\"") buildConfigField("String", "MDBLIST_API_KEY", "\"${mdblistApiKey}\"") buildConfigField("String", "ANILIST_CLIENT_ID", "\"${anilistClientId}\"") buildConfigField("String", "TRAKT_CLIENT_ID", "\"${traktClientId}\"") buildConfigField("String", "TRAKT_CLIENT_SECRET", "\"${traktClientSecret}\"") buildConfigField("String", "RD_CLIENT_ID", "\"${realDebridClientId}\"") buildConfigField("String", "TRAKT_REDIRECT_URI", "\"${traktRedirectUri}\"") buildConfigField("String", "TRAKT_API_URL", "\"${traktApiUrl}\"") buildConfigField("String", "PREMIUMIZE_CLIENT_ID", "\"${premiumizeClientId}\"") buildConfigField("String", "ADDON_CONFIG_WORKER_URL", "\"${addonConfigWorkerUrl}\"") buildConfigField("String", "ADDON_AUTH_WORKER_URL", "\"${addonAuthWorkerUrl}\"") buildConfigField("String", "ADDON_REGISTRY_BASE_URL", "\"${addonRegistryBaseUrl}\"") buildConfigField("String", "NEXSTREAM_SCRAPER_URL", "\"${nexstreamScraperUrl}\"") } signingConfigs { // Release signing: only active when keystore properties are supplied // (in CI or local ~/.gradle/gradle.properties). When unset the // release build type falls back to the default debug keystore, which // is acceptable for local/prototype builds but must be overridden for // any distribution build. val releaseStoreFile = (project.findProperty("RELEASE_STORE_FILE") as? String) if (!releaseStoreFile.isNullOrBlank()) { create("release") { storeFile = file(releaseStoreFile) storePassword = project.findProperty("RELEASE_STORE_PASSWORD") as? String ?: "" keyAlias = project.findProperty("RELEASE_KEY_ALIAS") as? String ?: "" keyPassword = project.findProperty("RELEASE_KEY_PASSWORD") as? String ?: "" } } } buildTypes { release { isMinifyEnabled = true isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) if (signingConfigs.names.contains("release")) { signingConfig = signingConfigs.getByName("release") } } debug { isMinifyEnabled = false applicationIdSuffix = ".debug" } } compileOptions { isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { jvmTarget = "21" } buildFeatures { compose = true buildConfig = true } packaging { resources { excludes += setOf( "/META-INF/{AL2.0,LGPL2.1}", "/META-INF/DEPENDENCIES", "/META-INF/LICENSE*", "/META-INF/NOTICE*", ) } jniLibs { // Keep one consistent native set across the forked ExoPlayer + FFmpeg // decoder AARs (mirrors NuvioTV). pickFirsts += listOf( "lib/*/libc++_shared.so", "lib/*/libffmpegJNI.so", ) } } } // Stock media3-exoplayer / media3-ui are replaced by the prebuilt forked AARs // (libs/lib-exoplayer-release.aar, libs/lib-ui-release.aar). Excluding them // globally prevents the Maven versions from colliding with the fork. configurations.all { exclude(group = "androidx.media3", module = "media3-exoplayer") exclude(group = "androidx.media3", module = "media3-ui") } dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") // Compose BOM val composeBom = platform("androidx.compose:compose-bom:2025.01.00") implementation(composeBom) androidTestImplementation(composeBom) // Compose for TV implementation("androidx.tv:tv-foundation:1.0.0") implementation("androidx.tv:tv-material:1.1.0") // Android TV Provider (WatchNext, PreviewPrograms, Channels) implementation("androidx.tvprovider:tvprovider:1.0.0") // Compose implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.runtime:runtime-livedata") implementation("androidx.activity:activity-compose:1.9.3") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") // Navigation implementation("androidx.navigation:navigation-compose:2.8.5") // Hilt DI implementation("com.google.dagger:hilt-android:2.53.1") ksp("com.google.dagger:hilt-android-compiler:2.53.1") implementation("androidx.hilt:hilt-navigation-compose:1.2.0") // Room DB implementation("androidx.room:room-runtime:2.7.1") implementation("androidx.room:room-ktx:2.7.1") ksp("androidx.room:room-compiler:2.7.1") // Retrofit + OkHttp implementation("com.squareup.retrofit2:retrofit:2.11.0") implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") // Gson (for YouTube InnerTube extraction) implementation("com.google.code.gson:gson:2.11.0") // HTML parsing for APK-native torrent sources (BitSearch / Nyaa / 1337x). implementation("org.jsoup:jsoup:1.22.2") // Kotlinx Serialization implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") // Coil (image loading) implementation("io.coil-kt.coil3:coil-compose:3.0.4") implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.4") // Glass blur sidebar (NuvioTV ModernSidebarBlurPanel pattern) implementation("dev.chrisbanes.haze:haze-android:0.7.3") // Media3 — mirrors NuvioTV's working audio pipeline. // media3-exoplayer and media3-ui are GLOBALLY EXCLUDED below and replaced by // the prebuilt forked AARs (lib-exoplayer-release.aar / lib-ui-release.aar), // which the lib-decoder-ffmpeg-release.aar is compiled against. Everything // else is stock Maven media3 at the matching 1.8.0 version. Mixing the fork // AAR with stock 1.5.1 was why FFmpeg audio (AC3/EAC3/DTS) produced no sound. implementation("androidx.media3:media3-exoplayer-hls:1.8.0") implementation("androidx.media3:media3-exoplayer-dash:1.8.0") implementation("androidx.media3:media3-datasource:1.8.0") implementation("androidx.media3:media3-datasource-okhttp:1.8.0") implementation("androidx.media3:media3-decoder:1.8.0") implementation("androidx.media3:media3-common:1.8.0") implementation("androidx.media3:media3-container:1.8.0") implementation("androidx.media3:media3-extractor:1.8.0") implementation("androidx.media3:media3-session:1.8.0") // PlayerView (lib-ui-release.aar) references RecyclerView for subtitle/track lists. implementation("androidx.recyclerview:recyclerview:1.4.0") // Forked ExoPlayer core + UI (replace the globally-excluded Maven modules) and // the FFmpeg software audio decoder (AC3/EAC3/DTS/TrueHD) built against them. implementation(files( "libs/lib-exoplayer-release.aar", "libs/lib-ui-release.aar", "libs/lib-decoder-av1-release.aar", "libs/lib-decoder-iamf-release.aar", "libs/lib-decoder-mpegh-release.aar", "libs/lib-decoder-ffmpeg-release.aar", )) // DataStore implementation("androidx.datastore:datastore-preferences:1.1.1") // WorkManager implementation("androidx.work:work-runtime-ktx:2.10.0") // Security (Keystore) implementation("androidx.security:security-crypto:1.1.0-alpha06") // Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") // Debug leak detection (debug-only; never shipped in release) debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14") // Baseline profile — AOT compilation for faster cold start implementation("androidx.profileinstaller:profileinstaller:1.4.1") // NanoHTTPD — embedded HTTP server for QR-code addon configuration implementation("org.nanohttpd:nanohttpd:2.3.1") // ZXing Core — QR code generation for addon config server implementation("com.google.zxing:core:3.5.3") // Testing testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") testImplementation("io.mockk:mockk:1.13.13") testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") }