Files
jellytau/src-tauri/android/app/build.gradle.kts
T
dtourolle bed1030443 feat(android): play the original file — decode Dolby/DTS audio with FFmpeg
Android ships no AC-3, E-AC-3, DTS or TrueHD decoders; they are licensed
codecs, present only where a vendor paid for them. The ROD2-W09 tablet has a
vendor DTS decoder and no AC-3/E-AC-3 at all. So every film with Dolby audio
was re-encoded by the server, for streaming and for download alike, and a
transcoded download has no Content-Length and ignores Range: ~1 MB/s,
restarting from byte zero on every network blip.

ExoPlayer now carries Jellyfin's media3 FFmpeg audio decoder in extension
mode ON (platform decoders first, FFmpeg for what they lack), and
CodecDetector reports its codecs so the device profile and the download
policy agree with what actually decodes. The download policy judges audio
against the renderer that will play the file (renderer_can_decode_audio)
instead of the webview's list, so Android downloads are always the direct
copy — a 910 MB E-AC-3 5.1 episode downloaded in 94 s and played offline.

The webview video path is removed on Android: it decodes none of these
codecs, so a stored "native video off" would play every original-file
download silent. Rust reports webview_video_fallback (false on Android, true
only beside mpv native video on Linux); Settings offers the switch and the
player honours it only then. Linux keeps the fallback and, with it, the
server transcode for undecodable audio.

The decoder is GPL-3.0; the distributed APK carries its terms and the source
stays MIT (THIRD_PARTY_NOTICES.md). The on-device remux spec this replaces is
folded into 05-platform-backends.md and deleted.

DR-293, UT-259, UT-262.
2026-09-22 22:22:05 -04:00

196 lines
9.3 KiB
Kotlin

import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
// Release signing: loaded from gen/android/keystore.properties if present.
// Falls back to no signing config (debug-signed) when the file is absent.
val keystoreProperties = Properties().apply {
val propFile = rootProject.file("keystore.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
// Side-by-side release: set by `scripts/build-android.sh release --debug`, which
// exports JT_SIDE_BY_SIDE=1. It puts a fully R8-minified release build into the
// debug applicationId slot, signed with the local debug keystore — so you can
// test what minification actually produces (R8 stripping JNI-loaded classes has
// broken release APKs here before) without the real signing key and without
// uninstalling your working install. Unset in CI, so distributable release
// builds are untouched.
val sideBySideRelease = System.getenv("JT_SIDE_BY_SIDE").let { it == "1" || it == "true" }
android {
compileSdk = 36
namespace = "com.dtourolle.jellytau"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
// Launcher/app names come from placeholders so the debug build can
// rename itself without touching the generated strings.xml (a
// resValue() override there would collide with Tauri's own entries).
manifestPlaceholders["appLabel"] = "@string/app_name"
manifestPlaceholders["activityLabel"] = "@string/main_activity_title"
applicationId = "com.dtourolle.jellytau"
minSdk = 24
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
// Required to run the on-device conformance suite
// (src/androidTest). See docs/specs/media-player-controller.md.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
keystoreProperties.getProperty("storeFile")?.let {
storeFile = file(it)
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
getByName("debug") {
// Distinct applicationId so the debug build installs SIDE BY SIDE
// with a release/store install instead of demanding an uninstall
// (different signing keys on the same package = INSTALL_FAILED_
// UPDATE_INCOMPATIBLE). It gets its own data dir, its own settings
// and its own offline cache — the two are fully independent apps.
//
// This changes only the *application* id. The Kotlin/JNI classes
// stay in the `namespace` package (com.dtourolle.jellytau), so the
// fully-qualified class names Rust looks up over JNI, the manifest
// <service> entry and the R8 keep rules are all unaffected. The
// FileProvider authority is already ${applicationId}-relative.
//
// The suffix itself is applied AFTER this block -- see the bottom of
// this file. It cannot live here.
versionNameSuffix = "-debug"
manifestPlaceholders["appLabel"] = "JellyTau Debug"
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
if (sideBySideRelease) {
// Same slot, name and version scheme as the debug build type,
// plus "-release" so you can tell from Settings > Apps which of
// the two is currently sitting there. Signed with the debug
// keystore: it shares a signature with the debug build, so the
// two replace each other cleanly instead of colliding.
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug-release"
manifestPlaceholders["appLabel"] = "JellyTau Debug"
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
signingConfig = signingConfigs.getByName("debug")
} else if (keystoreProperties.getProperty("storeFile") != null) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = true
proguardFiles(
*fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
}
}
// Java 17 bytecode. AGP 8.11 already requires a JDK 17 toolchain to run
// (the builder image ships openjdk-17), so "1.8" was only capping the
// bytecode we emit, not the JDK in use. Kotlin's jvmTarget and javac's
// source/targetCompatibility must agree or AGP 8 fails the build, so all
// three move together.
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
buildConfig = true
}
}
// The debug applicationId suffix, applied from OUTSIDE the `buildTypes` block.
//
// `tauri android build` rewrites the `getByName("debug")` block in the *generated*
// copy of this file (src-tauri/gen/android/app/build.gradle.kts) to inject its
// `jniLibs.keepDebugSymbols` entries -- you can see the damage in the generated
// file, where `packaging {` ends up with the first injected line welded onto it.
// That rewrite drops `applicationIdSuffix` and nothing else: `versionNameSuffix`
// and the manifest placeholders beside it survive. It happens after
// sync-android-sources.sh has copied this file into place and before Gradle
// configures, so no amount of syncing can beat it.
//
// The result was a debug APK whose applicationId was plain
// `com.dtourolle.jellytau`, colliding with a real release install:
// INSTALL_FAILED_UPDATE_INCOMPATIBLE, with the only obvious way out being to
// uninstall the release app and lose its data. The `sideBySideRelease` suffix in
// the *release* build type is untouched by the same rewrite, which is why that
// path kept working and this one did not.
//
// A top-level statement is not inside the block the rewriter looks for, so it
// survives. scripts/build-android.sh asserts the built applicationId afterwards,
// so a future CLI that reaches further fails the build instead of shipping a
// colliding APK.
android.buildTypes.getByName("debug").applicationIdSuffix = ".debug"
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
// Media3 dependencies for audio playback
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-common:1.5.0")
// SubtitleView. ExoPlayer delivers cues to a listener and draws none of them
// itself: without a view to hand them to, a selected subtitle track renders
// nowhere. See JellyTauPlayer.onCues. (DR-260)
implementation("androidx.media3:media3-ui:1.5.0")
// Software audio decoders for what Android does not ship: AC-3, E-AC-3,
// DTS and TrueHD are licensed codecs, present only where a vendor paid for
// them (the ROD2-W09 tablet has DTS but no AC-3/E-AC-3 at all). With this,
// ExoPlayer plays the source file as-is, so neither a download nor a stream
// needs the server to re-encode its audio (DR-293). Jellyfin's own build of
// the media3 FFmpeg extension, versioned to match media3 above — keep the
// two in step. Licence: GPL-3.0 — the distributed APK carries its terms,
// the source stays MIT; see THIRD_PARTY_NOTICES.md and
// docs/architecture/05-platform-backends.md.
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.5.0+1")
implementation("com.google.guava:guava:33.0.0-android")
// Media library for VolumeProviderCompat (remote volume control)
implementation("androidx.media:media:1.7.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
androidTestImplementation("androidx.test:runner:1.5.2")
}
apply(from = "tauri.build.gradle.kts")