diff --git a/extensions/nunl/build.gradle.kts b/extensions/nunl/build.gradle.kts
new file mode 100644
index 000000000..6020de901
--- /dev/null
+++ b/extensions/nunl/build.gradle.kts
@@ -0,0 +1,4 @@
+dependencies {
+ compileOnly(project(":extensions:shared:library"))
+ compileOnly(project(":extensions:nunl:stub"))
+}
diff --git a/extensions/nunl/src/main/AndroidManifest.xml b/extensions/nunl/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..9b65eb06c
--- /dev/null
+++ b/extensions/nunl/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/nunl/src/main/java/app/revanced/extension/nunl/ads/HideAdsPatch.java b/extensions/nunl/src/main/java/app/revanced/extension/nunl/ads/HideAdsPatch.java
new file mode 100644
index 000000000..fb3cd0c54
--- /dev/null
+++ b/extensions/nunl/src/main/java/app/revanced/extension/nunl/ads/HideAdsPatch.java
@@ -0,0 +1,114 @@
+package app.revanced.extension.nunl.ads;
+
+import nl.nu.performance.api.client.interfaces.Block;
+import nl.nu.performance.api.client.unions.SmallArticleLinkFlavor;
+import nl.nu.performance.api.client.objects.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import app.revanced.extension.shared.Logger;
+
+@SuppressWarnings("unused")
+public class HideAdsPatch {
+ private static final String[] blockedHeaderBlocks = {
+ "Aanbiedingen (Adverteerders)",
+ "Aangeboden door NUshop"
+ };
+
+ // "Rubrieken" menu links to ads.
+ private static final String[] blockedLinkBlocks = {
+ "Van onze adverteerders"
+ };
+
+ public static void filterAds(List blocks) {
+ try {
+ ArrayList cleanedList = new ArrayList<>();
+
+ boolean skipFullHeader = false;
+ boolean skipUntilDivider = false;
+
+ int index = 0;
+ while (index < blocks.size()) {
+ Block currentBlock = blocks.get(index);
+
+ // Because of pagination, we might not see the Divider in front of it.
+ // Just remove it as is and leave potential extra spacing visible on the screen.
+ if (currentBlock instanceof DpgBannerBlock) {
+ index++;
+ continue;
+ }
+
+ if (index + 1 < blocks.size()) {
+ // Filter Divider -> DpgMediaBanner -> Divider.
+ if (currentBlock instanceof DividerBlock
+ && blocks.get(index + 1) instanceof DpgBannerBlock) {
+ index += 2;
+ continue;
+ }
+
+ // Filter Divider -> LinkBlock (... -> LinkBlock -> LinkBlock-> LinkBlock -> Divider).
+ if (currentBlock instanceof DividerBlock
+ && blocks.get(index + 1) instanceof LinkBlock linkBlock) {
+ Link link = linkBlock.getLink();
+ if (link != null && link.getTitle() != null) {
+ for (String blockedLinkBlock : blockedLinkBlocks) {
+ if (blockedLinkBlock.equals(link.getTitle().getText())) {
+ skipUntilDivider = true;
+ break;
+ }
+ }
+ if (skipUntilDivider) {
+ index++;
+ continue;
+ }
+ }
+ }
+ }
+
+ // Skip LinkBlocks with a "flavor" claiming to be "isPartner" (sponsored inline ads).
+ if (currentBlock instanceof LinkBlock linkBlock
+ && linkBlock.getLink() != null
+ && linkBlock.getLink().getLinkFlavor() instanceof SmallArticleLinkFlavor smallArticleLinkFlavor
+ && smallArticleLinkFlavor.isPartner() != null
+ && smallArticleLinkFlavor.isPartner()) {
+ index++;
+ continue;
+ }
+
+ if (currentBlock instanceof DividerBlock) {
+ skipUntilDivider = false;
+ }
+
+ // Filter HeaderBlock with known ads until next HeaderBlock.
+ if (currentBlock instanceof HeaderBlock headerBlock) {
+ StyledText headerText = headerBlock.component20();
+ if (headerText != null) {
+ skipFullHeader = false;
+ for (String blockedHeaderBlock : blockedHeaderBlocks) {
+ if (blockedHeaderBlock.equals(headerText.getText())) {
+ skipFullHeader = true;
+ break;
+ }
+ }
+ if (skipFullHeader) {
+ index++;
+ continue;
+ }
+ }
+ }
+
+ if (!skipFullHeader && !skipUntilDivider) {
+ cleanedList.add(currentBlock);
+ }
+ index++;
+ }
+
+ // Replace list in-place to not deal with moving the result to the correct register in smali.
+ blocks.clear();
+ blocks.addAll(cleanedList);
+ } catch (Exception ex) {
+ Logger.printException(() -> "filterAds failure", ex);
+ }
+ }
+}
diff --git a/extensions/nunl/stub/build.gradle.kts b/extensions/nunl/stub/build.gradle.kts
new file mode 100644
index 000000000..a8da923ed
--- /dev/null
+++ b/extensions/nunl/stub/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ id(libs.plugins.android.library.get().pluginId)
+}
+
+android {
+ namespace = "app.revanced.extension"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 26
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
diff --git a/extensions/nunl/stub/src/main/AndroidManifest.xml b/extensions/nunl/stub/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..9b65eb06c
--- /dev/null
+++ b/extensions/nunl/stub/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/interfaces/Block.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/interfaces/Block.java
new file mode 100644
index 000000000..3514f360c
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/interfaces/Block.java
@@ -0,0 +1,5 @@
+package nl.nu.performance.api.client.interfaces;
+
+public class Block {
+
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DividerBlock.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DividerBlock.java
new file mode 100644
index 000000000..0351aec04
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DividerBlock.java
@@ -0,0 +1,7 @@
+package nl.nu.performance.api.client.objects;
+
+import nl.nu.performance.api.client.interfaces.Block;
+
+public class DividerBlock extends Block {
+
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DpgBannerBlock.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DpgBannerBlock.java
new file mode 100644
index 000000000..ac300b053
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DpgBannerBlock.java
@@ -0,0 +1,7 @@
+package nl.nu.performance.api.client.objects;
+
+import nl.nu.performance.api.client.interfaces.Block;
+
+public class DpgBannerBlock extends Block {
+
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/HeaderBlock.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/HeaderBlock.java
new file mode 100644
index 000000000..f946b54da
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/HeaderBlock.java
@@ -0,0 +1,10 @@
+package nl.nu.performance.api.client.objects;
+
+import nl.nu.performance.api.client.interfaces.Block;
+
+public class HeaderBlock extends Block {
+ // returns title
+ public final StyledText component20() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/Link.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/Link.java
new file mode 100644
index 000000000..771d11dad
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/Link.java
@@ -0,0 +1,13 @@
+package nl.nu.performance.api.client.objects;
+
+import nl.nu.performance.api.client.unions.LinkFlavor;
+
+public class Link {
+ public final StyledText getTitle() {
+ throw new UnsupportedOperationException("Stub");
+ }
+
+ public final LinkFlavor getLinkFlavor() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/LinkBlock.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/LinkBlock.java
new file mode 100644
index 000000000..dea195057
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/LinkBlock.java
@@ -0,0 +1,10 @@
+package nl.nu.performance.api.client.objects;
+
+import android.os.Parcelable;
+import nl.nu.performance.api.client.interfaces.Block;
+
+public abstract class LinkBlock extends Block implements Parcelable {
+ public final Link getLink() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/StyledText.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/StyledText.java
new file mode 100644
index 000000000..719403eb4
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/StyledText.java
@@ -0,0 +1,7 @@
+package nl.nu.performance.api.client.objects;
+
+public class StyledText {
+ public final String getText() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/LinkFlavor.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/LinkFlavor.java
new file mode 100644
index 000000000..08413d3fd
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/LinkFlavor.java
@@ -0,0 +1,4 @@
+package nl.nu.performance.api.client.unions;
+
+public interface LinkFlavor {
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/SmallArticleLinkFlavor.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/SmallArticleLinkFlavor.java
new file mode 100644
index 000000000..4dcbf23cb
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/SmallArticleLinkFlavor.java
@@ -0,0 +1,7 @@
+package nl.nu.performance.api.client.unions;
+
+public class SmallArticleLinkFlavor implements LinkFlavor {
+ public final Boolean isPartner() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/patches/api/patches.api b/patches/api/patches.api
index 2ac53b545..ef2702984 100644
--- a/patches/api/patches.api
+++ b/patches/api/patches.api
@@ -348,6 +348,14 @@ public final class app/revanced/patches/nfctoolsse/misc/pro/UnlockProPatchKt {
public static final fun getUnlockProPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
}
+public final class app/revanced/patches/nunl/ads/HideAdsPatchKt {
+ public static final fun getHideAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
+}
+
+public final class app/revanced/patches/nunl/firebase/SpoofCertificatePatchKt {
+ public static final fun getSpoofCertificatePatch ()Lapp/revanced/patcher/patch/BytecodePatch;
+}
+
public final class app/revanced/patches/nyx/misc/pro/UnlockProPatchKt {
public static final fun getUnlockProPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
}
diff --git a/patches/src/main/kotlin/app/revanced/patches/nunl/ads/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/nunl/ads/Fingerprints.kt
new file mode 100644
index 000000000..8332f2f24
--- /dev/null
+++ b/patches/src/main/kotlin/app/revanced/patches/nunl/ads/Fingerprints.kt
@@ -0,0 +1,44 @@
+package app.revanced.patches.nunl.ads
+
+import app.revanced.patcher.fingerprint
+import com.android.tools.smali.dexlib2.AccessFlags
+import com.android.tools.smali.dexlib2.Opcode
+
+internal val jwUtilCreateAdvertisementFingerprint = fingerprint {
+ accessFlags(AccessFlags.PRIVATE, AccessFlags.STATIC)
+ custom { methodDef, classDef ->
+ classDef.type == "Lnl/sanomamedia/android/nu/video/util/JWUtil;" && methodDef.name == "createAdvertising"
+ }
+}
+
+internal val screenMapperFingerprint = fingerprint {
+ accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL)
+ returns("Lnl/nu/android/bff/domain/models/screen/ScreenEntity;")
+ parameters("Lnl/nu/performance/api/client/objects/Screen;")
+
+ opcodes(
+ Opcode.MOVE_RESULT_OBJECT,
+ Opcode.IF_EQZ,
+ Opcode.CHECK_CAST
+ )
+
+ custom { methodDef, classDef ->
+ classDef.type == "Lnl/nu/android/bff/data/mappers/ScreenMapper;" && methodDef.name == "map"
+ }
+}
+
+internal val nextPageRepositoryImplFingerprint = fingerprint {
+ accessFlags(AccessFlags.PRIVATE, AccessFlags.FINAL)
+ returns("Lnl/nu/android/bff/domain/models/Page;")
+ parameters("Lnl/nu/performance/api/client/PacResponse;", "Ljava/lang/String;")
+
+ opcodes(
+ Opcode.MOVE_RESULT_OBJECT,
+ Opcode.IF_EQZ,
+ Opcode.CHECK_CAST
+ )
+
+ custom { methodDef, classDef ->
+ classDef.type == "Lnl/nu/android/bff/data/repositories/NextPageRepositoryImpl;" && methodDef.name == "mapToPage"
+ }
+}
diff --git a/patches/src/main/kotlin/app/revanced/patches/nunl/ads/HideAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/nunl/ads/HideAdsPatch.kt
new file mode 100644
index 000000000..c09ce25e9
--- /dev/null
+++ b/patches/src/main/kotlin/app/revanced/patches/nunl/ads/HideAdsPatch.kt
@@ -0,0 +1,51 @@
+package app.revanced.patches.nunl.ads
+
+import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
+import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
+import app.revanced.patcher.patch.bytecodePatch
+import app.revanced.patches.shared.misc.extension.sharedExtensionPatch
+import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction
+
+@Suppress("unused")
+val hideAdsPatch = bytecodePatch(
+ name = "Hide ads",
+ description = "Hide ads and sponsored articles in list pages and remove pre-roll ads on videos.",
+) {
+ compatibleWith("nl.sanomamedia.android.nu"("11.0.0", "11.0.1", "11.1.0"))
+
+ dependsOn(sharedExtensionPatch("nunl", mainActivityOnCreateHook))
+
+ execute {
+ // Disable video pre-roll ads.
+ // Whenever the app tries to create an ad via JWUtils.createAdvertising, don't actually tell the underlying JWPlayer library to do so => JWPlayer will not display ads.
+ jwUtilCreateAdvertisementFingerprint.method.addInstructions(
+ 0,
+ """
+ new-instance v0, Lcom/jwplayer/pub/api/configuration/ads/VastAdvertisingConfig${'$'}Builder;
+ invoke-direct { v0 }, Lcom/jwplayer/pub/api/configuration/ads/VastAdvertisingConfig${'$'}Builder;->()V
+ invoke-virtual { v0 }, Lcom/jwplayer/pub/api/configuration/ads/VastAdvertisingConfig${'$'}Builder;->build()Lcom/jwplayer/pub/api/configuration/ads/VastAdvertisingConfig;
+ move-result-object v0
+ return-object v0
+ """,
+ )
+
+ // Filter injected content from API calls out of lists.
+ arrayOf(screenMapperFingerprint, nextPageRepositoryImplFingerprint).forEach {
+ // Index of instruction moving result of BlockPage;->getBlocks(...).
+ val moveGetBlocksResultObjectIndex = it.patternMatch!!.startIndex
+ it.method.apply {
+ val moveInstruction = getInstruction(moveGetBlocksResultObjectIndex)
+
+ val listRegister = moveInstruction.registerA
+
+ // Add instruction after moving List to register and then filter this List in place.
+ addInstructions(
+ moveGetBlocksResultObjectIndex + 1,
+ """
+ invoke-static { v$listRegister }, Lapp/revanced/extension/nunl/ads/HideAdsPatch;->filterAds(Ljava/util/List;)V
+ """,
+ )
+ }
+ }
+ }
+}
diff --git a/patches/src/main/kotlin/app/revanced/patches/nunl/ads/Hooks.kt b/patches/src/main/kotlin/app/revanced/patches/nunl/ads/Hooks.kt
new file mode 100644
index 000000000..9a2e28bca
--- /dev/null
+++ b/patches/src/main/kotlin/app/revanced/patches/nunl/ads/Hooks.kt
@@ -0,0 +1,9 @@
+package app.revanced.patches.nunl.ads
+
+import app.revanced.patches.shared.misc.extension.extensionHook
+
+internal val mainActivityOnCreateHook = extensionHook {
+ custom { method, classDef ->
+ classDef.type == "Lnl/sanomamedia/android/nu/main/NUMainActivity;" && method.name == "onCreate"
+ }
+}
diff --git a/patches/src/main/kotlin/app/revanced/patches/nunl/firebase/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/nunl/firebase/Fingerprints.kt
new file mode 100644
index 000000000..490819a4b
--- /dev/null
+++ b/patches/src/main/kotlin/app/revanced/patches/nunl/firebase/Fingerprints.kt
@@ -0,0 +1,20 @@
+package app.revanced.patches.nunl.firebase
+
+import app.revanced.patcher.fingerprint
+import com.android.tools.smali.dexlib2.AccessFlags
+
+internal val getFingerprintHashForPackageFingerprints = arrayOf(
+ "Lcom/google/firebase/installations/remote/FirebaseInstallationServiceClient;",
+ "Lcom/google/firebase/remoteconfig/internal/ConfigFetchHttpClient;",
+ "Lcom/google/firebase/remoteconfig/internal/ConfigRealtimeHttpClient;"
+).map { className ->
+ fingerprint {
+ accessFlags(AccessFlags.PRIVATE)
+ parameters()
+ returns("Ljava/lang/String;")
+
+ custom { methodDef, classDef ->
+ classDef.type == className && methodDef.name == "getFingerprintHashForPackage"
+ }
+ }
+}
diff --git a/patches/src/main/kotlin/app/revanced/patches/nunl/firebase/SpoofCertificatePatch.kt b/patches/src/main/kotlin/app/revanced/patches/nunl/firebase/SpoofCertificatePatch.kt
new file mode 100644
index 000000000..b87211251
--- /dev/null
+++ b/patches/src/main/kotlin/app/revanced/patches/nunl/firebase/SpoofCertificatePatch.kt
@@ -0,0 +1,24 @@
+package app.revanced.patches.nunl.firebase
+
+import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
+import app.revanced.patcher.patch.bytecodePatch
+
+@Suppress("unused")
+val spoofCertificatePatch = bytecodePatch(
+ name = "Spoof certificate",
+ description = "Spoofs the X-Android-Cert header to allow push messages.",
+) {
+ compatibleWith("nl.sanomamedia.android.nu")
+
+ execute {
+ getFingerprintHashForPackageFingerprints.forEach { fingerprint ->
+ fingerprint.method.addInstructions(
+ 0,
+ """
+ const-string v0, "eae41fc018df2731a9b6ae1ac327da44a288667b"
+ return-object v0
+ """,
+ )
+ }
+ }
+}