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