From 42622a1bca8cdd38117deca309afd260cca220d2 Mon Sep 17 00:00:00 2001 From: whitehai11 Date: Thu, 3 Sep 2026 20:50:51 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01NXRsEcWGqDhsCbfEdrfCdE --- .../patches/spotify/misc/skip/Fingerprints.kt | 37 ++++++++++++++ .../patches/spotify/misc/skip/SkipPatch.kt | 50 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 patches/src/main/kotlin/app/revanced/patches/spotify/misc/skip/Fingerprints.kt create mode 100644 patches/src/main/kotlin/app/revanced/patches/spotify/misc/skip/SkipPatch.kt diff --git a/patches/src/main/kotlin/app/revanced/patches/spotify/misc/skip/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/skip/Fingerprints.kt new file mode 100644 index 000000000..cefc1fed6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/skip/Fingerprints.kt @@ -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()), + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/spotify/misc/skip/SkipPatch.kt b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/skip/SkipPatch.kt new file mode 100644 index 000000000..5cf970453 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/skip/SkipPatch.kt @@ -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) + } +}