merge: stop backing up credentials no key can ever open (B2, B4, B5)

allowBackup=false plus data_extraction_rules covering device-transfer, not just
cloud-backup; treat an undecryptable credential blob as a logout rather than a
hard error; drop the half-declared leanback/TV entries; jvmTarget 1.8 -> 17.
This commit is contained in:
2026-08-16 23:01:05 +02:00
5 changed files with 227 additions and 13 deletions
+38 -5
View File
@@ -27,16 +27,50 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<!--
Android TV is deliberately NOT declared here.
A LEANBACK_LAUNCHER category and an android.software.leanback uses-feature
used to sit in this manifest, but nothing behind them: no D-pad focus
model, no TV-sized layouts, and neither of the two declarations Play's TV
validation also requires (android.hardware.touchscreen required="false"
and an android:banner). That combination is the worst of both - it offers
the app to TV launchers while failing TV review and shipping a UI that
cannot be driven without a touchscreen.
Re-declare all four together (leanback feature, LEANBACK_LAUNCHER,
touchscreen required="false", banner) once a focus pass has actually been
done, not before.
-->
<!--
android:allowBackup / android:dataExtractionRules below:
no cloud backup, no device-to-device transfer (UR-012).
Credentials are encrypted under an Android Keystore key, and Keystore keys
are NEVER backed up. A restored install would therefore get the
jellytau_secure_prefs ciphertext with no key to open it - the app would
look signed in and silently fail every request, which is worse than a
login screen. Everything else in the data dir (the SQLite catalogue:
library metadata, watch history, download bookkeeping) is a rebuildable
mirror of the Jellyfin server, so backing it up buys nothing and exports
the user's library and viewing history to their Google account.
allowBackup covers API 24-30 completely, and kills *cloud* backup on API
31+. It does NOT stop device-to-device transfer there, so
@xml/data_extraction_rules (API 31+) excludes both channels explicitly. No
android:fullBackupContent is needed: over the API 23-30 range where it
would govern, allowBackup="false" has already turned backup off entirely.
-->
<application
android:icon="@mipmap/ic_launcher"
android:label="${appLabel}"
android:theme="@style/Theme.jellytau"
android:hardwareAccelerated="true"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="${usesCleartextTraffic}">
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
android:launchMode="singleTask"
@@ -48,8 +82,7 @@
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<!-- No LEANBACK_LAUNCHER: see the Android TV note above. -->
</intent-filter>
</activity>
@@ -100,9 +100,27 @@ class SecureStorage private constructor(context: Context) {
}
}
/**
* Read a credential.
*
* Returns null for both "nothing stored" and "stored but undecryptable", but
* treats them as distinct events. The second happens after a backup restore
* or a device-to-device transfer: SharedPreferences travel, the Android
* Keystore key that encrypted them never does, so the ciphertext can never
* be read again on this install. That blob is discarded here rather than
* left to fail on every subsequent read, which turns a permanently broken
* credential into a clean logged-out state. (The app also declares
* allowBackup="false" plus data-extraction rules so this should no longer
* arise - this is the belt to that manifest's braces.)
*/
fun getCredential(key: String): String? {
try {
val encoded = prefs.getString(key, null) ?: return null
val encoded = prefs.getString(key, null)
if (encoded == null) {
Log.d(TAG, "No credential stored for: $key")
return null
}
return try {
val combined = Base64.decode(encoded, Base64.DEFAULT)
// Extract IV (first 12 bytes for GCM)
@@ -114,10 +132,16 @@ class SecureStorage private constructor(context: Context) {
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
val decrypted = cipher.doFinal(encrypted)
return String(decrypted, Charsets.UTF_8)
String(decrypted, Charsets.UTF_8)
} catch (e: Exception) {
Log.e(TAG, "Failed to get credential: $key", e)
return null
Log.w(
TAG,
"Credential '$key' is present but cannot be decrypted; discarding it and " +
"reporting no credential. Signing in again will store a fresh one.",
e
)
prefs.edit().remove(key).apply()
null
}
}
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Backup / transfer policy for JellyTau (API 31+; see android:allowBackup in
AndroidManifest.xml for API 24-30).
Nothing is eligible for extraction, from either channel:
* cloud-backup - already off via android:allowBackup="false".
* device-transfer - NOT covered by allowBackup on Android 12+, which is why
this file exists. A D2D transfer would otherwise copy the same data the
cloud backup used to.
Why nothing is extractable:
* Credentials are unrecoverable off-device. jellytau_secure_prefs holds
AES-GCM ciphertext encrypted under an Android Keystore key, and Keystore
keys are never backed up or transferred. Restoring the prefs without the
key produces ciphertext nothing can read - a silent auth failure that looks
like a broken app rather than a logged-out one.
* Everything else is a rebuildable cache. The SQLite catalogue is a mirror of
the Jellyfin server (library metadata, watch history, offline downloads);
signing in again reproduces it, and watch state lives on the server anyway.
Backing it up would export a user's library and viewing history to their
Google account for no gain.
Exclude rules are listed per domain rather than relying on "root" alone,
because database/, shared_prefs/, files/ and external storage are addressed
as their own domains by the extraction engine.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>