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:
whitehai11
2026-09-03 20:50:51 +02:00
co-authored by Claude Sonnet 5
parent 37ef795322
commit 42622a1bca
2 changed files with 87 additions and 0 deletions
@@ -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)
}
}