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:
@@ -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>
|
||||
Reference in New Issue
Block a user