feat(Spotify): Add Remove skip restrictions patch
Adds an opt-in patch that forces the local Restrictions.disallowSkippingNextReasons() getter (read by every skip button in the app) to always report an empty set, and forces the rapid-skip cooldown tracker to always behave like it does after the first-ever skip. This removes the free-tier "6 skips per hour" cap and its toasts, and as a side effect also unlocks the skip button while an ad is playing, since it reads the same restriction. This is purely a client-side behavior change: it does not report a Premium account to Spotify's backend, and does not block or mute ad audio itself. Verified against a real Spotify 9.1.78.2215 APK via revanced-cli: both fingerprints resolve and the patch applies cleanly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NXRsEcWGqDhsCbfEdrfCdE
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
37ef795322
commit
42622a1bca
@@ -0,0 +1,37 @@
|
|||||||
|
package app.revanced.patches.spotify.misc.skip
|
||||||
|
|
||||||
|
import app.revanced.patcher.*
|
||||||
|
import app.revanced.patcher.patch.BytecodePatchContext
|
||||||
|
import app.revanced.util.literal
|
||||||
|
import com.android.tools.smali.dexlib2.AccessFlags
|
||||||
|
|
||||||
|
// Getter on the locally-held player Restrictions model (deserialized from local player
|
||||||
|
// state, not fetched fresh from the server per call) that lists the reasons skipping to
|
||||||
|
// the next track is currently disallowed. A free-tier account gets a non-empty set here
|
||||||
|
// once the "6 skips per hour" cap is hit, and it is also checked while an ad is playing
|
||||||
|
// to decide whether the skip button is enabled. Every skip button in the app (Now Playing
|
||||||
|
// bar, ad track, notification, etc.) reads this same getter, so overriding it to always
|
||||||
|
// report "no restrictions" removes the skip cap and unlocks skipping ads, without ever
|
||||||
|
// telling the server the account's plan changed.
|
||||||
|
internal val BytecodePatchContext.disallowSkippingNextReasonsMethod by gettingFirstMethodDeclaratively {
|
||||||
|
name("disallowSkippingNextReasons")
|
||||||
|
accessFlags(AccessFlags.PUBLIC)
|
||||||
|
parameterTypes()
|
||||||
|
// Restrictions itself only declares the getter abstractly; skip past it to the
|
||||||
|
// AutoValue-generated implementation, which is the one with an actual method body.
|
||||||
|
custom { !AccessFlags.ABSTRACT.isSet(accessFlags) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local (client-side) rapid-skip cooldown tracker: keeps the timestamps of the last 3
|
||||||
|
// skips in a LinkedList and returns 3 ("Slow down! You get 6 skips per hour.") if all 3
|
||||||
|
// happened within 10 seconds (0x2710 ms) of each other, 2 (first-ever-skip "You now have
|
||||||
|
// 6 skips per hour" toast) the very first time it's called, or 1 (no toast) otherwise.
|
||||||
|
internal val BytecodePatchContext.freeTierRapidSkipCounterMethodMatch by composingFirstMethod {
|
||||||
|
accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL)
|
||||||
|
returnType("I")
|
||||||
|
parameterTypes()
|
||||||
|
instructions(
|
||||||
|
method { name == "elapsedRealtime" && returnType == "J" },
|
||||||
|
afterAtMost(50, 10000L()),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package app.revanced.patches.spotify.misc.skip
|
||||||
|
|
||||||
|
import app.revanced.patcher.extensions.addInstructions
|
||||||
|
import app.revanced.patcher.firstClassDef
|
||||||
|
import app.revanced.patcher.patch.bytecodePatch
|
||||||
|
import app.revanced.util.returnEarly
|
||||||
|
import com.android.tools.smali.dexlib2.AccessFlags
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
val removeSkipRestrictionsPatch = bytecodePatch(
|
||||||
|
name = "Remove skip restrictions",
|
||||||
|
description = "Removes the free-tier \"6 skips per hour\" limit and its toasts/upsells, " +
|
||||||
|
"and unlocks the skip button while an ad is playing. This only changes local " +
|
||||||
|
"client-side behavior; it does not report a Premium account to Spotify.",
|
||||||
|
use = false,
|
||||||
|
) {
|
||||||
|
compatibleWith("com.spotify.music")
|
||||||
|
|
||||||
|
apply {
|
||||||
|
// Force the Restrictions getter that every skip button reads to always report
|
||||||
|
// that skipping is allowed, by having it construct and return an empty instance
|
||||||
|
// of its own (obfuscated) collection return type instead of the real field.
|
||||||
|
disallowSkippingNextReasonsMethod.apply {
|
||||||
|
val emptyCollectionType = returnType
|
||||||
|
|
||||||
|
val emptyCollectionFactoryMethod = firstClassDef(emptyCollectionType).methods.first {
|
||||||
|
AccessFlags.STATIC.isSet(it.accessFlags) &&
|
||||||
|
it.returnType == emptyCollectionType &&
|
||||||
|
it.parameterTypes.map(Any::toString) == listOf("I", "[Ljava/lang/Object;")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuses "this" (p0) as a scratch register instead of requiring a second
|
||||||
|
// local, since everything below this early return becomes dead code.
|
||||||
|
addInstructions(
|
||||||
|
0,
|
||||||
|
"""
|
||||||
|
const/4 v0, 0x0
|
||||||
|
new-array p0, v0, [Ljava/lang/Object;
|
||||||
|
invoke-static { v0, p0 }, ${emptyCollectionFactoryMethod.definingClass}->${emptyCollectionFactoryMethod.name}(I[Ljava/lang/Object;)$emptyCollectionType
|
||||||
|
move-result-object v0
|
||||||
|
return-object v0
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force the rapid-skip cooldown tracker to always behave like it does after the
|
||||||
|
// first-ever skip (return 1, no toast), instead of counting towards the cap.
|
||||||
|
freeTierRapidSkipCounterMethodMatch.method.returnEarly(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user