Compare commits

...

128 Commits

Author SHA1 Message Date
semantic-release-bot
a2304c3310 chore: Release v5.41.0-dev.1 [skip ci]
# [5.41.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.40.1-dev.1...v5.41.0-dev.1) (2025-09-21)

### Features

* **YouTube Music:** Add `Sanitize sharing links` patch ([#5952](https://github.com/ReVanced/revanced-patches/issues/5952)) ([45c1ee8](45c1ee8a12))
2025-09-21 17:19:16 +00:00
LisoUseInAIKyrios
45c1ee8a12 feat(YouTube Music): Add Sanitize sharing links patch (#5952) 2025-09-21 21:14:19 +04:00
github-actions[bot]
74cdf550a5 chore: Sync translations (#5953) 2025-09-21 21:14:03 +04:00
semantic-release-bot
c36ea22975 chore: Release v5.40.1-dev.1 [skip ci]
## [5.40.1-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.40.0...v5.40.1-dev.1) (2025-09-21)

### Bug Fixes

* **YouTube - Return YouTube Dislike:** Do not show error toast if API returns 401 status ([#5949](https://github.com/ReVanced/revanced-patches/issues/5949)) ([58d088a](58d088ab30))
* **YouTube - Settings:** Use an overlay to show search results ([#5806](https://github.com/ReVanced/revanced-patches/issues/5806)) ([ece8076](ece8076f7c))
2025-09-21 13:25:05 +00:00
LisoUseInAIKyrios
58d088ab30 fix(YouTube - Return YouTube Dislike): Do not show error toast if API returns 401 status (#5949) 2025-09-21 17:20:13 +04:00
MarcaD
ece8076f7c fix(YouTube - Settings): Use an overlay to show search results (#5806)
Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
2025-09-21 17:19:29 +04:00
semantic-release-bot
ebb446b22a chore: Release v5.40.0 [skip ci]
# [5.40.0](https://github.com/ReVanced/revanced-patches/compare/v5.39.0...v5.40.0) (2025-09-21)

### Bug Fixes

* **Instagram - Limit feed to followed profiles:** Change patch to default off ([767f1e3](767f1e3695))
* **Spoof video streams:** Resolve occasional playback stuttering ([5c7c8b5](5c7c8b5364))
* **YouTube - Force original audio:** Show UI setting summary if spoofing to Android Studio ([b7026b7](b7026b7086))
* **YouTube - Spoof video streams:** Add "Force original audio" disclaimer for Android Studio client ([f97d332](f97d33206b))
* **YouTube - Spoof video streams:** Add stream audio selector disclaimer for Android Studio client ([a8a4107](a8a410708d))

### Features

* **Instagram:** Add `Limit feed to followed profiles` patch ([#5908](https://github.com/ReVanced/revanced-patches/issues/5908)) ([8ba9a19](8ba9a19ade))
* **Viber - Hide ads:** Support latest app target ([#5863](https://github.com/ReVanced/revanced-patches/issues/5863)) ([e6cce85](e6cce85541))
* **YouTube - Hide video action buttons:** Add "Hide comments" button ([db796fb](db796fb883))
* **YouTube Music:** Add `Enable debugging` patch ([#5939](https://github.com/ReVanced/revanced-patches/issues/5939)) ([418f594](418f5945c2))
* **YouTube Music:** Add `Hide cast button` and `Navigation bar` patches ([#5934](https://github.com/ReVanced/revanced-patches/issues/5934)) ([651d358](651d358096))
* **YouTube Music:** Support version `8.10.52` ([#5941](https://github.com/ReVanced/revanced-patches/issues/5941)) ([01c0f1b](01c0f1bd1a))
* **YouTube:** Support version `20.14.43` ([#5940](https://github.com/ReVanced/revanced-patches/issues/5940)) ([f7f4a1b](f7f4a1b0f0))
2025-09-21 06:58:02 +00:00
LisoUseInAIKyrios
b44a369f59 chore: Merge branch dev to main (#5916) 2025-09-21 10:54:28 +04:00
github-actions[bot]
092a72c774 chore: Sync translations (#5946) 2025-09-21 10:52:29 +04:00
LisoUseInAIKyrios
6330773bfc chore(YouTube Music): Add missing target version 2025-09-21 10:50:35 +04:00
LisoUseInAIKyrios
43dbb4710b docs: Add new issue links to the FAQ and troubleshooting guide (#5929) 2025-09-21 10:42:02 +04:00
LisoUseInAIKyrios
966727ca2d chore(YouTube Music): Use string language similar to YouTube 2025-09-20 23:31:01 +04:00
semantic-release-bot
1f371c8156 chore: Release v5.40.0-dev.11 [skip ci]
# [5.40.0-dev.11](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.10...v5.40.0-dev.11) (2025-09-20)

### Bug Fixes

* **YouTube - Spoof video streams:** Add stream audio selector disclaimer for Android Studio client ([a8a4107](a8a410708d))
2025-09-20 19:20:13 +00:00
LisoUseInAIKyrios
a8a410708d fix(YouTube - Spoof video streams): Add stream audio selector disclaimer for Android Studio client 2025-09-20 23:15:41 +04:00
semantic-release-bot
7651ef0881 chore: Release v5.40.0-dev.10 [skip ci]
# [5.40.0-dev.10](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.9...v5.40.0-dev.10) (2025-09-20)

### Bug Fixes

* **YouTube - Spoof video streams:** Add "Force original audio" disclaimer for Android Studio client ([f97d332](f97d33206b))
2025-09-20 18:13:06 +00:00
LisoUseInAIKyrios
f97d33206b fix(YouTube - Spoof video streams): Add "Force original audio" disclaimer for Android Studio client 2025-09-20 22:08:50 +04:00
semantic-release-bot
3d986e6716 chore: Release v5.40.0-dev.9 [skip ci]
# [5.40.0-dev.9](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.8...v5.40.0-dev.9) (2025-09-20)

### Features

* **YouTube Music:** Support version `8.10.52` ([#5941](https://github.com/ReVanced/revanced-patches/issues/5941)) ([01c0f1b](01c0f1bd1a))
2025-09-20 16:12:57 +00:00
LisoUseInAIKyrios
01c0f1bd1a feat(YouTube Music): Support version 8.10.52 (#5941) 2025-09-20 20:09:52 +04:00
github-actions[bot]
4178e8a64f chore: Sync translations (#5943) 2025-09-20 20:09:07 +04:00
semantic-release-bot
7e1bb8f3c7 chore: Release v5.40.0-dev.8 [skip ci]
# [5.40.0-dev.8](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.7...v5.40.0-dev.8) (2025-09-20)

### Features

* **YouTube:** Support version `20.14.43` ([#5940](https://github.com/ReVanced/revanced-patches/issues/5940)) ([f7f4a1b](f7f4a1b0f0))
2025-09-20 15:33:42 +00:00
LisoUseInAIKyrios
f7f4a1b0f0 feat(YouTube): Support version 20.14.43 (#5940) 2025-09-20 19:30:05 +04:00
semantic-release-bot
e89660d234 chore: Release v5.40.0-dev.7 [skip ci]
# [5.40.0-dev.7](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.6...v5.40.0-dev.7) (2025-09-20)

### Features

* **YouTube - Hide video action buttons:** Add "Hide comments" button ([db796fb](db796fb883))
2025-09-20 15:03:00 +00:00
LisoUseInAIKyrios
db796fb883 feat(YouTube - Hide video action buttons): Add "Hide comments" button
Button is only shown when using YouTube 20.14+ and the video information area is collapsed to a compact state
2025-09-20 19:00:00 +04:00
LisoUseInAIKyrios
6bb8bad8d7 chore(YouTube Music): Fix fingerprint typo, change hide cast button to default off 2025-09-20 18:03:41 +04:00
semantic-release-bot
aa1fb41ad8 chore: Release v5.40.0-dev.6 [skip ci]
# [5.40.0-dev.6](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.5...v5.40.0-dev.6) (2025-09-20)

### Features

* **YouTube Music:** Add `Enable debugging` patch ([#5939](https://github.com/ReVanced/revanced-patches/issues/5939)) ([418f594](418f5945c2))
2025-09-20 12:37:33 +00:00
LisoUseInAIKyrios
418f5945c2 feat(YouTube Music): Add Enable debugging patch (#5939) 2025-09-20 16:33:03 +04:00
github-actions[bot]
e26c971067 chore: Sync translations (#5942) 2025-09-20 16:32:50 +04:00
semantic-release-bot
eb1d07fd98 chore: Release v5.40.0-dev.5 [skip ci]
# [5.40.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.4...v5.40.0-dev.5) (2025-09-20)

### Features

* **YouTube Music:** Add `Hide cast button` and `Navigation bar` patches ([#5934](https://github.com/ReVanced/revanced-patches/issues/5934)) ([651d358](651d358096))
2025-09-20 11:30:04 +00:00
MarcaD
651d358096 feat(YouTube Music): Add Hide cast button and Navigation bar patches (#5934) 2025-09-20 15:26:14 +04:00
semantic-release-bot
0d15c5f338 chore: Release v5.40.0-dev.4 [skip ci]
# [5.40.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.3...v5.40.0-dev.4) (2025-09-20)

### Bug Fixes

* **Spoof video streams:** Resolve occasional playback stuttering ([5c7c8b5](5c7c8b5364))
2025-09-20 10:39:29 +00:00
LisoUseInAIKyrios
5c7c8b5364 fix(Spoof video streams): Resolve occasional playback stuttering
Code adapted from:
2cf9db66ac
50d9c60374
2025-09-20 14:36:15 +04:00
semantic-release-bot
729997ec3e chore: Release v5.40.0-dev.3 [skip ci]
# [5.40.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.2...v5.40.0-dev.3) (2025-09-19)

### Bug Fixes

* **Instagram - Limit feed to followed profiles:** Change patch to default off ([767f1e3](767f1e3695))
2025-09-19 15:43:08 +00:00
LisoUseInAIKyrios
767f1e3695 fix(Instagram - Limit feed to followed profiles): Change patch to default off
Co-authored-by: brosssh <44944126+brosssh@users.noreply.github.com>
2025-09-19 19:40:32 +04:00
github-actions[bot]
7857876551 chore: Sync translations (#5933) 2025-09-19 19:40:03 +04:00
semantic-release-bot
04057c6e56 chore: Release v5.40.0-dev.2 [skip ci]
# [5.40.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.1...v5.40.0-dev.2) (2025-09-18)

### Features

* **Instagram:** Add `Limit feed to followed profiles` patch ([#5908](https://github.com/ReVanced/revanced-patches/issues/5908)) ([8ba9a19](8ba9a19ade))
2025-09-18 06:16:27 +00:00
brosssh
8ba9a19ade feat(Instagram): Add Limit feed to followed profiles patch (#5908) 2025-09-18 10:13:46 +04:00
LisoUseInAIKyrios
6862200a28 chore: Fix api dump 2025-09-17 23:42:11 +04:00
semantic-release-bot
dfff3d7c0a chore: Release v5.40.0-dev.1 [skip ci]
# [5.40.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.39.1-dev.1...v5.40.0-dev.1) (2025-09-17)

### Features

* **Viber - Hide ads:** Support latest app target ([#5863](https://github.com/ReVanced/revanced-patches/issues/5863)) ([e6cce85](e6cce85541))
2025-09-17 17:54:19 +00:00
Samo Hribar
e6cce85541 feat(Viber - Hide ads): Support latest app target (#5863) 2025-09-17 21:51:33 +04:00
github-actions[bot]
8502eb8eac chore: Sync translations (#5918) 2025-09-17 21:51:15 +04:00
semantic-release-bot
0652c56d0d chore: Release v5.39.1-dev.1 [skip ci]
## [5.39.1-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.39.0...v5.39.1-dev.1) (2025-09-17)

### Bug Fixes

* **YouTube - Force original audio:** Show UI setting summary if spoofing to Android Studio ([b7026b7](b7026b7086))
2025-09-17 16:18:22 +00:00
LisoUseInAIKyrios
b7026b7086 fix(YouTube - Force original audio): Show UI setting summary if spoofing to Android Studio 2025-09-17 20:13:44 +04:00
semantic-release-bot
fa4f422a15 chore: Release v5.39.0 [skip ci]
# [5.39.0](https://github.com/ReVanced/revanced-patches/compare/v5.38.0...v5.39.0) (2025-09-17)

### Bug Fixes

* **YouTube - Spoof video streams:** Do not use Android Creator for livestreams ([cbe576b](cbe576bc38))
* **YouTube - Spoof video streams:** Show Android Studio in spoof stream menu ([c9f741e](c9f741e616))
* **YouTube Music - Spoof video streams:** Remove iPadOS client ([7eeffd3](7eeffd3392))

### Features

* **YouTube - Hide video action buttons:** Add "Hide Shop button" setting ([a84db7b](a84db7be7f))
2025-09-17 09:15:36 +00:00
LisoUseInAIKyrios
38e0cbd724 chore: Merge branch dev to main (#5907) 2025-09-17 13:12:21 +04:00
semantic-release-bot
0bdebd927d chore: Release v5.39.0-dev.2 [skip ci]
# [5.39.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.39.0-dev.1...v5.39.0-dev.2) (2025-09-17)

### Bug Fixes

* **YouTube - Spoof video streams:** Show Android Studio in spoof stream menu ([c9f741e](c9f741e616))
2025-09-17 09:01:12 +00:00
github-actions[bot]
3eac25cf7f chore: Sync translations (#5914) 2025-09-17 12:56:47 +04:00
LisoUseInAIKyrios
c9f741e616 fix(YouTube - Spoof video streams): Show Android Studio in spoof stream menu 2025-09-17 12:54:52 +04:00
semantic-release-bot
cba44ccfc8 chore: Release v5.39.0-dev.1 [skip ci]
# [5.39.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.38.1-dev.2...v5.39.0-dev.1) (2025-09-17)

### Features

* **YouTube - Hide video action buttons:** Add "Hide Shop button" setting ([a84db7b](a84db7be7f))
2025-09-17 07:19:06 +00:00
LisoUseInAIKyrios
a84db7be7f feat(YouTube - Hide video action buttons): Add "Hide Shop button" setting 2025-09-17 11:14:24 +04:00
semantic-release-bot
2520129ace chore: Release v5.38.1-dev.2 [skip ci]
## [5.38.1-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.38.1-dev.1...v5.38.1-dev.2) (2025-09-16)

### Bug Fixes

* **YouTube Music - Spoof video streams:** Remove iPadOS client ([7eeffd3](7eeffd3392))
2025-09-16 21:49:36 +00:00
LisoUseInAIKyrios
7eeffd3392 fix(YouTube Music - Spoof video streams): Remove iPadOS client 2025-09-17 01:44:48 +04:00
LisoUseInAIKyrios
6c3391164e chore: Remove spoof stream data migration since iPadOS can cause 1 minute playback failure for users in some regions 2025-09-16 23:44:01 +04:00
semantic-release-bot
0b8b46c73e chore: Release v5.38.1-dev.1 [skip ci]
## [5.38.1-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.38.0...v5.38.1-dev.1) (2025-09-16)

### Bug Fixes

* **YouTube - Spoof video streams:** Do not use Android Creator for livestreams ([cbe576b](cbe576bc38))
2025-09-16 19:27:03 +00:00
LisoUseInAIKyrios
cbe576bc38 fix(YouTube - Spoof video streams): Do not use Android Creator for livestreams 2025-09-16 23:24:07 +04:00
github-actions[bot]
3a29f2a805 chore: Sync translations (#5909) 2025-09-16 23:21:01 +04:00
LisoUseInAIKyrios
50069c7e05 chore: Fix merge typo 2025-09-16 17:26:48 +04:00
semantic-release-bot
2e9c9dc244 chore: Release v5.38.0 [skip ci]
# [5.38.0](https://github.com/ReVanced/revanced-patches/compare/v5.37.0...v5.38.0) (2025-09-16)

### Bug Fixes

* **Instagram - Hide navigation buttons:** Support v397.1.0.52.81 ([#5855](https://github.com/ReVanced/revanced-patches/issues/5855)) ([f11d1ef](f11d1ef990))
* **Spoof video streams:** Remove Android TV and iOS TV clients, add experimental VisionOS, add temporary fix for `Force original audio` to work with any spoof client ([#5861](https://github.com/ReVanced/revanced-patches/issues/5861)) ([abe3943](abe3943f98))
* **YouTube - Spoof video streams:** Show settings summary if `Force original audio` is enabled ([3776dda](3776dda710))
* **YouTube Music - Spoof video streams:** Fix playback issues when using a cellular network ([fa04c8e](fa04c8eecf))
* **YouTube Music:** Use correct light/dark mode settings UI ([1475643](1475643f84))

### Features

* **Instagram:** Add `Hide explore feed` patch ([#5856](https://github.com/ReVanced/revanced-patches/issues/5856)) ([1d65887](1d65887e01))
* **YouTube - Spoof video streams:** Add iPadOS client ([2726231](2726231404))
* **YouTube Music:** Add `Settings` patch ([#5838](https://github.com/ReVanced/revanced-patches/issues/5838)) ([5e20bd8](5e20bd80f1))
2025-09-16 13:01:23 +00:00
LisoUseInAIKyrios
56166896d9 chore: Merge branch dev to main (#5857) 2025-09-16 16:57:55 +04:00
semantic-release-bot
b4c695b1d5 chore: Release v5.38.0-dev.5 [skip ci]
# [5.38.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v5.38.0-dev.4...v5.38.0-dev.5) (2025-09-16)

### Bug Fixes

* **YouTube Music:** Use correct light/dark mode settings UI ([1475643](1475643f84))
2025-09-16 12:34:52 +00:00
LisoUseInAIKyrios
1475643f84 fix(YouTube Music): Use correct light/dark mode settings UI 2025-09-16 16:31:04 +04:00
github-actions[bot]
9a7179f9cf chore: Sync translations (#5906) 2025-09-16 16:29:53 +04:00
semantic-release-bot
6fb94a7a41 chore: Release v5.38.0-dev.4 [skip ci]
# [5.38.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.38.0-dev.3...v5.38.0-dev.4) (2025-09-16)

### Bug Fixes

* **YouTube - Spoof video streams:** Show settings summary if `Force original audio` is enabled ([3776dda](3776dda710))
2025-09-16 12:05:23 +00:00
LisoUseInAIKyrios
3776dda710 fix(YouTube - Spoof video streams): Show settings summary if Force original audio is enabled 2025-09-16 15:59:32 +04:00
LisoUseInAIKyrios
f88b3a5162 refactor(YouTube - Spoof video streams): Adjust preferred client order 2025-09-16 15:40:55 +04:00
semantic-release-bot
0eeaf7ad67 chore: Release v5.38.0-dev.3 [skip ci]
# [5.38.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.38.0-dev.2...v5.38.0-dev.3) (2025-09-16)

### Features

* **YouTube - Spoof video streams:** Add iPadOS client ([2726231](2726231404))
2025-09-16 11:36:54 +00:00
LisoUseInAIKyrios
2726231404 feat(YouTube - Spoof video streams): Add iPadOS client 2025-09-16 15:33:55 +04:00
github-actions[bot]
9f0558e494 chore: Sync translations (#5905) 2025-09-16 15:11:04 +04:00
semantic-release-bot
01f7bc9f8d chore: Release v5.38.0-dev.2 [skip ci]
# [5.38.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.38.0-dev.1...v5.38.0-dev.2) (2025-09-16)

### Features

* **YouTube Music:** Add `Settings` patch ([#5838](https://github.com/ReVanced/revanced-patches/issues/5838)) ([5e20bd8](5e20bd80f1))
2025-09-16 06:57:43 +00:00
MarcaD
5e20bd80f1 feat(YouTube Music): Add Settings patch (#5838)
Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
2025-09-16 10:53:49 +04:00
semantic-release-bot
f304c178e2 chore: Release v5.38.0-dev.1 [skip ci]
# [5.38.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.37.1-dev.3...v5.38.0-dev.1) (2025-09-15)

### Features

* **Instagram:** Add `Hide explore feed` patch ([#5856](https://github.com/ReVanced/revanced-patches/issues/5856)) ([1d65887](1d65887e01))
2025-09-15 19:30:52 +00:00
brosssh
1d65887e01 feat(Instagram): Add Hide explore feed patch (#5856) 2025-09-15 23:28:01 +04:00
github-actions[bot]
6b6eea8414 chore: Sync translations (#5864) 2025-09-15 23:26:07 +04:00
semantic-release-bot
1db131e90e chore: Release v5.37.1-dev.3 [skip ci]
## [5.37.1-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.37.1-dev.2...v5.37.1-dev.3) (2025-09-15)

### Bug Fixes

* **Spoof video streams:** Remove Android TV and iOS TV clients, add experimental VisionOS, add temporary fix for `Force original audio` to work with any spoof client ([#5861](https://github.com/ReVanced/revanced-patches/issues/5861)) ([abe3943](abe3943f98))
2025-09-15 17:02:01 +00:00
LisoUseInAIKyrios
abe3943f98 fix(Spoof video streams): Remove Android TV and iOS TV clients, add experimental VisionOS, add temporary fix for Force original audio to work with any spoof client (#5861) 2025-09-15 20:58:56 +04:00
semantic-release-bot
cb6d802de3 chore: Release v5.37.1-dev.2 [skip ci]
## [5.37.1-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.37.1-dev.1...v5.37.1-dev.2) (2025-09-15)

### Bug Fixes

* **Instagram - Hide navigation buttons:** Support v397.1.0.52.81 ([#5855](https://github.com/ReVanced/revanced-patches/issues/5855)) ([f11d1ef](f11d1ef990))
2025-09-15 12:52:54 +00:00
brosssh
f11d1ef990 fix(Instagram - Hide navigation buttons): Support v397.1.0.52.81 (#5855) 2025-09-15 16:48:55 +04:00
semantic-release-bot
3d25da18bc chore: Release v5.37.1-dev.1 [skip ci]
## [5.37.1-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.37.0...v5.37.1-dev.1) (2025-09-15)

### Bug Fixes

* **YouTube Music - Spoof video streams:** Fix playback issues when using a cellular network ([fa04c8e](fa04c8eecf))
2025-09-15 12:47:02 +00:00
LisoUseInAIKyrios
fa04c8eecf fix(YouTube Music - Spoof video streams): Fix playback issues when using a cellular network
Code adapted from 5f35e51a27
2025-09-15 16:43:04 +04:00
semantic-release-bot
105f6e0e97 chore: Release v5.37.0 [skip ci]
# [5.37.0](https://github.com/ReVanced/revanced-patches/compare/v5.36.0...v5.37.0) (2025-09-15)

### Bug Fixes

* **Instagram - Hide navigation buttons:** Add constrain to known working version ([e6c79f1](e6c79f1383))
* Resolve patching with dev branch ([09b941a](09b941abf0))
* **Spotify:** Remove broken `Spoof client` patch ([#5833](https://github.com/ReVanced/revanced-patches/issues/5833)) ([dcd4245](dcd42454bd))
* **Viber - Hide ads:** Add constrain to known working version ([2db0948](2db0948bea))
* **YouTube Music - Spoof streaming data:** Fix audio playback stuttering ([#5839](https://github.com/ReVanced/revanced-patches/issues/5839)) ([2a85a3b](2a85a3b290))

### Features

* **Viber:** Add `Hide ads` patch ([#5826](https://github.com/ReVanced/revanced-patches/issues/5826)) ([0abfab7](0abfab79d7))
2025-09-15 06:45:56 +00:00
LisoUseInAIKyrios
7d59efe05d chore: Merge branch dev to main (#5830) 2025-09-15 10:43:05 +04:00
github-actions[bot]
81ff5576b0 chore: Sync translations (#5854) 2025-09-15 10:41:42 +04:00
semantic-release-bot
9a5c102c0d chore: Release v5.37.0-dev.6 [skip ci]
# [5.37.0-dev.6](https://github.com/ReVanced/revanced-patches/compare/v5.37.0-dev.5...v5.37.0-dev.6) (2025-09-15)

### Bug Fixes

* **Instagram - Hide navigation buttons:** Add constrain to known working version ([e6c79f1](e6c79f1383))
2025-09-15 06:40:30 +00:00
LisoUseInAIKyrios
e6c79f1383 fix(Instagram - Hide navigation buttons): Add constrain to known working version 2025-09-15 10:36:57 +04:00
semantic-release-bot
2a582eced8 chore: Release v5.37.0-dev.5 [skip ci]
# [5.37.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v5.37.0-dev.4...v5.37.0-dev.5) (2025-09-15)

### Bug Fixes

* **Viber - Hide ads:** Add constrain to known working version ([2db0948](2db0948bea))
2025-09-15 06:29:31 +00:00
LisoUseInAIKyrios
2db0948bea fix(Viber - Hide ads): Add constrain to known working version 2025-09-15 10:26:30 +04:00
semantic-release-bot
a3ba92e742 chore: Release v5.37.0-dev.4 [skip ci]
# [5.37.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.37.0-dev.3...v5.37.0-dev.4) (2025-09-14)

### Bug Fixes

* **YouTube Music - Spoof streaming data:** Fix audio playback stuttering ([#5839](https://github.com/ReVanced/revanced-patches/issues/5839)) ([2a85a3b](2a85a3b290))
2025-09-14 18:22:57 +00:00
LisoUseInAIKyrios
2a85a3b290 fix(YouTube Music - Spoof streaming data): Fix audio playback stuttering (#5839) 2025-09-14 22:19:13 +04:00
semantic-release-bot
eee72208dd chore: Release v5.37.0-dev.3 [skip ci]
# [5.37.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.37.0-dev.2...v5.37.0-dev.3) (2025-09-14)

### Bug Fixes

* **Spotify:** Remove broken `Spoof client` patch ([#5833](https://github.com/ReVanced/revanced-patches/issues/5833)) ([dcd4245](dcd42454bd))
2025-09-14 17:15:28 +00:00
LisoUseInAIKyrios
dcd42454bd fix(Spotify): Remove broken Spoof client patch (#5833) 2025-09-14 21:11:15 +04:00
LisoUseInAIKyrios
782353c18a refactor(Spoof video streams): Handle migration of default spoof client for users upgrading from very old patches 2025-09-14 18:06:40 +04:00
semantic-release-bot
b53b870e8f chore: Release v5.37.0-dev.2 [skip ci]
# [5.37.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.37.0-dev.1...v5.37.0-dev.2) (2025-09-14)

### Bug Fixes

* Resolve patching with dev branch ([09b941a](09b941abf0))
2025-09-14 12:00:38 +00:00
LisoUseInAIKyrios
09b941abf0 fix: Resolve patching with dev branch 2025-09-14 15:58:05 +04:00
semantic-release-bot
678ef4052e chore: Release v5.37.0-dev.1 [skip ci]
# [5.37.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.36.0...v5.37.0-dev.1) (2025-09-14)

### Features

* **Viber:** Add `Hide ads` patch ([#5826](https://github.com/ReVanced/revanced-patches/issues/5826)) ([0abfab7](0abfab79d7))
2025-09-14 11:52:21 +00:00
Samo Hribar
0abfab79d7 feat(Viber): Add Hide ads patch (#5826) 2025-09-14 15:49:52 +04:00
LisoUseInAIKyrios
61cadf72cd refactor(Spoof video streams): Back port code from v22 branch to support patching the latest YT Music. Using any target above 7.49.52 is untested and only recommended for experimental or development purposes. 2025-09-14 15:49:35 +04:00
github-actions[bot]
e12359b94f chore: Sync translations (#5829) 2025-09-14 15:46:32 +04:00
semantic-release-bot
c001daba4a chore: Release v5.36.0 [skip ci]
# [5.36.0](https://github.com/ReVanced/revanced-patches/compare/v5.35.0...v5.36.0) (2025-09-14)

### Bug Fixes

* **Duolingo - Disable ads:** Support latest app target ([#5782](https://github.com/ReVanced/revanced-patches/issues/5782)) ([88b47ef](88b47ef414))
* **YouTube - Hide layout components:** Hide new type of Playable shelf ([8cd8e59](8cd8e59bbc))
* **YouTube Music:** Resolve playback issues, change recommended app target to `7.29.52` ([#5813](https://github.com/ReVanced/revanced-patches/issues/5813)) ([a53b00d](a53b00dd51))

### Features

* **YouTube - SponsorBlock:** Add 'Hook' segment category ([#5783](https://github.com/ReVanced/revanced-patches/issues/5783)) ([9d4aa5c](9d4aa5cd16))
2025-09-14 06:56:22 +00:00
LisoUseInAIKyrios
e136f62d6e chore: Merge branch dev to main (#5800) 2025-09-14 10:53:28 +04:00
semantic-release-bot
8ec405a359 chore: Release v5.36.0-dev.1 [skip ci]
# [5.36.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.35.0...v5.36.0-dev.1) (2025-09-13)

### Bug Fixes

* **Duolingo - Disable ads:** Support latest app target ([#5782](https://github.com/ReVanced/revanced-patches/issues/5782)) ([88b47ef](88b47ef414))
* **YouTube - Hide layout components:** Hide new type of Playable shelf ([8cd8e59](8cd8e59bbc))
* **YouTube Music:** Resolve playback issues, change recommended app target to `7.29.52` ([#5813](https://github.com/ReVanced/revanced-patches/issues/5813)) ([a53b00d](a53b00dd51))

### Features

* **YouTube - SponsorBlock:** Add 'Hook' segment category ([#5783](https://github.com/ReVanced/revanced-patches/issues/5783)) ([9d4aa5c](9d4aa5cd16))
2025-09-13 15:31:54 +00:00
github-actions[bot]
2f4b3a887b chore: Sync translations (#5821) 2025-09-13 19:28:15 +04:00
semantic-release-bot
d1fabb242b chore: Release v5.36.0-dev.1 [skip ci]
# [5.36.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.35.0...v5.36.0-dev.1) (2025-09-13)

### Bug Fixes

* **Duolingo - Disable ads:** Support latest app target ([#5782](https://github.com/ReVanced/revanced-patches/issues/5782)) ([88b47ef](88b47ef414))
* **YouTube - Hide layout components:** Hide new type of Playable shelf ([8cd8e59](8cd8e59bbc))
* **YouTube Music:** Resolve playback issues, change recommended app target to `7.29.52` ([#5813](https://github.com/ReVanced/revanced-patches/issues/5813)) ([a53b00d](a53b00dd51))

### Features

* **YouTube - SponsorBlock:** Add 'Hook' segment category ([#5783](https://github.com/ReVanced/revanced-patches/issues/5783)) ([9d4aa5c](9d4aa5cd16))
2025-09-13 15:15:40 +00:00
LisoUseInAIKyrios
a53b00dd51 fix(YouTube Music): Resolve playback issues, change recommended app target to 7.29.52 (#5813) 2025-09-13 19:12:00 +04:00
semantic-release-bot
850c13e98e chore: Release v5.36.0-dev.1 [skip ci]
# [5.36.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.35.0...v5.36.0-dev.1) (2025-09-13)

### Bug Fixes

* **Duolingo - Disable ads:** Support latest app target ([#5782](https://github.com/ReVanced/revanced-patches/issues/5782)) ([88b47ef](88b47ef414))
* **YouTube - Hide layout components:** Hide new type of Playable shelf ([8cd8e59](8cd8e59bbc))

### Features

* **YouTube - SponsorBlock:** Add 'Hook' segment category ([#5783](https://github.com/ReVanced/revanced-patches/issues/5783)) ([9d4aa5c](9d4aa5cd16))
2025-09-13 07:00:16 +00:00
LisoUseInAIKyrios
4310789a26 chore: Fix api 2025-09-13 10:56:43 +04:00
semantic-release-bot
c4a720fbd3 chore: Release v5.36.0-dev.1 [skip ci]
# [5.36.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.35.0...v5.36.0-dev.1) (2025-09-12)

### Bug Fixes

* **Duolingo - Disable ads:** Support latest app target ([#5782](https://github.com/ReVanced/revanced-patches/issues/5782)) ([88b47ef](88b47ef414))
* **YouTube - Hide layout components:** Hide new type of Playable shelf ([8cd8e59](8cd8e59bbc))

### Features

* **YouTube - SponsorBlock:** Add 'Hook' segment category ([#5783](https://github.com/ReVanced/revanced-patches/issues/5783)) ([9d4aa5c](9d4aa5cd16))
2025-09-12 15:08:03 +00:00
LisoUseInAIKyrios
3bdb8dbce0 chore(YouTube - SponsorBlock): Adjust strings for consistency / clarity
Strings taken from https://github.com/ajayyy/ExtensionTranslations/blob/master/en/messages.json
2025-09-12 18:49:34 +04:00
LisoUseInAIKyrios
4894f33c96 chore: fix compilation 2025-09-12 18:49:33 +04:00
semantic-release-bot
7f6093ee66 chore: Release v5.36.0-dev.1 [skip ci]
# [5.36.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.35.1-dev.1...v5.36.0-dev.1) (2025-09-12)

### Features

* **YouTube - SponsorBlock:** Add 'Hook' segment category ([#5783](https://github.com/ReVanced/revanced-patches/issues/5783)) ([9d4aa5c](9d4aa5cd16))
2025-09-12 01:59:47 +00:00
LisoUseInAIKyrios
9d4aa5cd16 feat(YouTube - SponsorBlock): Add 'Hook' segment category (#5783) 2025-09-12 05:56:50 +04:00
oSumAtrIX
5ace6f587c chore: Add ads.fund verification file [skip ci] (#5786) 2025-09-11 16:00:24 +02:00
semantic-release-bot
796f56745e chore: Release v5.35.1-dev.1 [skip ci]
## [5.35.1-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.35.0...v5.35.1-dev.1) (2025-09-11)

### Bug Fixes

* **Duolingo - Disable ads:** Support latest app target ([#5782](https://github.com/ReVanced/revanced-patches/issues/5782)) ([88b47ef](88b47ef414))
* **YouTube - Hide layout components:** Hide new type of Playable shelf ([8cd8e59](8cd8e59bbc))
2025-09-11 01:29:16 +00:00
hoodles
88b47ef414 fix(Duolingo - Disable ads): Support latest app target (#5782) 2025-09-11 03:26:28 +02:00
LisoUseInAIKyrios
8cd8e59bbc fix(YouTube - Hide layout components): Hide new type of Playable shelf 2025-09-11 03:24:09 +02:00
LisoUseInAIKyrios
6e72b14d07 refactor(YouTube - Video Quality): Handle extremely slow internet connections that initially can use -1 quality index 2025-09-11 02:58:33 +02:00
LisoUseInAIKyrios
52b088327b chore: Fix api dump 2025-09-10 21:58:27 +02:00
semantic-release-bot
8e934cc56b chore: Release v5.35.0 [skip ci]
# [5.35.0](https://github.com/ReVanced/revanced-patches/compare/v5.34.0...v5.35.0) (2025-09-09)

### Bug Fixes

* **Instagram - Hide navigation buttons:** Fix Manager patching error ([0a8cd7a](0a8cd7a7db))
* **Proton mail:** Constrain patches to last working app target ([1895291](189529151a))
* Revert dependency updates to fix Manager pre-release patching ([9256aa4](9256aa4548))
* **Spotify - Unlock Premium:** Make compatible with latest versions again by fixing fingerprint ([#5684](https://github.com/ReVanced/revanced-patches/issues/5684)) ([23496c7](23496c7c36))
* **YouTube - Hide layout components:** Hide Playable shelf header ([1473db0](1473db0bef))

### Features

* **BaconReader:** Add `Fix Redgifs API` patch ([#5761](https://github.com/ReVanced/revanced-patches/issues/5761)) ([144af2f](144af2f07e))
* **Boost/Sync for Reddit:** Add `Fix Redgifs` patch  ([#5725](https://github.com/ReVanced/revanced-patches/issues/5725)) ([c66c42e](c66c42e946))
* **Instagram:** Add `Hide navigation buttons` patch ([#5678](https://github.com/ReVanced/revanced-patches/issues/5678)) ([1dbc2d4](1dbc2d4057))
* **Instagram:** Add `Hide Stories from Home` patch ([#5756](https://github.com/ReVanced/revanced-patches/issues/5756)) ([b8629aa](b8629aacb6))
2025-09-09 19:38:18 +00:00
LisoUseInAIKyrios
b3140d909b chore: Merge branch dev to main (#5691) 2025-09-09 21:34:30 +02:00
github-actions[bot]
97645aa9f4 chore: Sync translations (#5777) 2025-09-09 21:32:56 +02:00
semantic-release-bot
603e2d018c chore: Release v5.35.0-dev.5 [skip ci]
# [5.35.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v5.35.0-dev.4...v5.35.0-dev.5) (2025-09-06)

### Features

* **BaconReader:** Add `Fix Redgifs API` patch ([#5761](https://github.com/ReVanced/revanced-patches/issues/5761)) ([144af2f](144af2f07e))
* **Instagram:** Add `Hide Stories from Home` patch ([#5756](https://github.com/ReVanced/revanced-patches/issues/5756)) ([b8629aa](b8629aacb6))
2025-09-06 10:56:54 +00:00
Eric Ahn
144af2f07e feat(BaconReader): Add Fix Redgifs API patch (#5761) 2025-09-06 12:53:26 +02:00
PainfulPaladins
b8629aacb6 feat(Instagram): Add Hide Stories from Home patch (#5756) 2025-09-06 12:53:08 +02:00
github-actions[bot]
3951527f51 chore: Sync translations (#5768) 2025-09-06 12:52:48 +02:00
semantic-release-bot
7a8b618c4e chore: Release v5.35.0-dev.4 [skip ci]
# [5.35.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.35.0-dev.3...v5.35.0-dev.4) (2025-09-04)

### Features

* **Boost/Sync for Reddit:** Add `Fix Redgifs` patch  ([#5725](https://github.com/ReVanced/revanced-patches/issues/5725)) ([c66c42e](c66c42e946))
2025-09-04 21:33:33 +00:00
Eric Ahn
c66c42e946 feat(Boost/Sync for Reddit): Add Fix Redgifs patch (#5725) 2025-09-04 23:29:58 +02:00
semantic-release-bot
b340769cf3 chore: Release v5.35.0-dev.3 [skip ci]
# [5.35.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.35.0-dev.2...v5.35.0-dev.3) (2025-09-04)

### Bug Fixes

* **Instagram - Hide navigation buttons:** Fix Manager patching error ([0a8cd7a](0a8cd7a7db))
2025-09-04 14:06:03 +00:00
LisoUseInAIKyrios
0a8cd7a7db fix(Instagram - Hide navigation buttons): Fix Manager patching error 2025-09-04 16:01:50 +02:00
semantic-release-bot
39f90e4b11 chore: Release v5.35.0-dev.2 [skip ci]
# [5.35.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.35.0-dev.1...v5.35.0-dev.2) (2025-09-04)

### Bug Fixes

* Revert dependency updates to fix Manager pre-release patching ([9256aa4](9256aa4548))
2025-09-04 10:27:39 +00:00
LisoUseInAIKyrios
9256aa4548 fix: Revert dependency updates to fix Manager pre-release patching 2025-09-04 12:23:56 +02:00
371 changed files with 18861 additions and 8307 deletions

View File

@@ -72,6 +72,7 @@ body:
- **Do not submit a duplicate bug report**: Search for existing bug reports [here](https://github.com/ReVanced/revanced-patches/issues?q=label%3A%22Bug+report%22).
- **Review the contribution guidelines**: Make sure your bug report adheres to it. You can find the guidelines [here](https://github.com/ReVanced/revanced-patches/blob/main/CONTRIBUTING.md).
- **Check the troubleshooting guide**: A solution to your issue might be found in the [FAQ](https://github.com/ReVanced/revanced-documentation/blob/main/docs/revanced-resources/questions.md) or the [troubleshooting guide](https://github.com/ReVanced/revanced-documentation/blob/main/docs/revanced-resources/troubleshooting.md).
- **Do not use the issue page for support**: If you need help or have questions, check out other platforms on [revanced.app](https://revanced.app).
- type: textarea
attributes:

View File

@@ -72,6 +72,7 @@ body:
- **Do not submit a duplicate feature request**: Search for existing feature requests [here](https://github.com/ReVanced/revanced-patches/issues?q=label%3A%22Feature+request%22).
- **Review the contribution guidelines**: Make sure your feature request adheres to it. You can find the guidelines [here](https://github.com/ReVanced/revanced-patches/blob/main/CONTRIBUTING.md).
- **Check the troubleshooting guide**: Information about your issue might be found in the [FAQ](https://github.com/ReVanced/revanced-documentation/blob/main/docs/revanced-resources/questions.md) or the [troubleshooting guide](https://github.com/ReVanced/revanced-documentation/blob/main/docs/revanced-resources/troubleshooting.md).
- **Do not use the issue page for support**: If you need help or have questions, check out other platforms on [revanced.app](https://revanced.app).
- type: textarea
attributes:

View File

@@ -12,10 +12,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v5
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Open pull request
uses: repo-sync/pull-request@v2

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
ref: dev
clean: true

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Preprocess strings
env:

View File

@@ -18,10 +18,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v5
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
@@ -51,14 +51,14 @@ jobs:
fingerprint: ${{ vars.GPG_FINGERPRINT }}
- name: Release
uses: cycjimmy/semantic-release-action@v5
uses: cycjimmy/semantic-release-action@v4
id: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Attest
if: steps.release.outputs.new_release_published == 'true'
uses: actions/attest-build-provenance@v3
uses: actions/attest-build-provenance@v2
with:
subject-name: 'ReVanced Patches ${{ steps.release.outputs.new_release_git_tag }}'
subject-path: patches/build/libs/patches-*.rvp

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Update Gradle Wrapper
uses: gradle-update/update-gradle-wrapper-action@v1

View File

@@ -1,3 +1,429 @@
# [5.41.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.40.1-dev.1...v5.41.0-dev.1) (2025-09-21)
### Features
* **YouTube Music:** Add `Sanitize sharing links` patch ([#5952](https://github.com/ReVanced/revanced-patches/issues/5952)) ([45c1ee8](https://github.com/ReVanced/revanced-patches/commit/45c1ee8a12dc777a371875d90741a05cf5d8e9dd))
## [5.40.1-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.40.0...v5.40.1-dev.1) (2025-09-21)
### Bug Fixes
* **YouTube - Return YouTube Dislike:** Do not show error toast if API returns 401 status ([#5949](https://github.com/ReVanced/revanced-patches/issues/5949)) ([58d088a](https://github.com/ReVanced/revanced-patches/commit/58d088ab307440a6912a867246da799b7dd6499b))
* **YouTube - Settings:** Use an overlay to show search results ([#5806](https://github.com/ReVanced/revanced-patches/issues/5806)) ([ece8076](https://github.com/ReVanced/revanced-patches/commit/ece8076f7cefd752b97515014bc50fe4fd80171e))
# [5.40.0](https://github.com/ReVanced/revanced-patches/compare/v5.39.0...v5.40.0) (2025-09-21)
### Bug Fixes
* **Instagram - Limit feed to followed profiles:** Change patch to default off ([767f1e3](https://github.com/ReVanced/revanced-patches/commit/767f1e3695327bdbc4daea8b50a80d4c0a38456a))
* **Spoof video streams:** Resolve occasional playback stuttering ([5c7c8b5](https://github.com/ReVanced/revanced-patches/commit/5c7c8b536416ec53cd98f7d59d11850aa1b70f11))
* **YouTube - Force original audio:** Show UI setting summary if spoofing to Android Studio ([b7026b7](https://github.com/ReVanced/revanced-patches/commit/b7026b70865bc44de07b30f84ba8b8b608930d5b))
* **YouTube - Spoof video streams:** Add "Force original audio" disclaimer for Android Studio client ([f97d332](https://github.com/ReVanced/revanced-patches/commit/f97d33206b4c97244f0bd0c672c4b91eaf477b0b))
* **YouTube - Spoof video streams:** Add stream audio selector disclaimer for Android Studio client ([a8a4107](https://github.com/ReVanced/revanced-patches/commit/a8a410708d50f34ac4bd2ca29bbbc3cde00bbf93))
### Features
* **Instagram:** Add `Limit feed to followed profiles` patch ([#5908](https://github.com/ReVanced/revanced-patches/issues/5908)) ([8ba9a19](https://github.com/ReVanced/revanced-patches/commit/8ba9a19ade24c5fe9bd6d4e49772b7663522780e))
* **Viber - Hide ads:** Support latest app target ([#5863](https://github.com/ReVanced/revanced-patches/issues/5863)) ([e6cce85](https://github.com/ReVanced/revanced-patches/commit/e6cce8554116df3c0ea6dbb7440c59c9e73d8334))
* **YouTube - Hide video action buttons:** Add "Hide comments" button ([db796fb](https://github.com/ReVanced/revanced-patches/commit/db796fb8830b813e1ed626d491c4a797171e69e7))
* **YouTube Music:** Add `Enable debugging` patch ([#5939](https://github.com/ReVanced/revanced-patches/issues/5939)) ([418f594](https://github.com/ReVanced/revanced-patches/commit/418f5945c213313f9a77cac9a5c326d89c754dfd))
* **YouTube Music:** Add `Hide cast button` and `Navigation bar` patches ([#5934](https://github.com/ReVanced/revanced-patches/issues/5934)) ([651d358](https://github.com/ReVanced/revanced-patches/commit/651d3580967a252b57cbf4afbba02d6a4601ccfe))
* **YouTube Music:** Support version `8.10.52` ([#5941](https://github.com/ReVanced/revanced-patches/issues/5941)) ([01c0f1b](https://github.com/ReVanced/revanced-patches/commit/01c0f1bd1ac6edb8aea758f88ffffcdea74a29b7))
* **YouTube:** Support version `20.14.43` ([#5940](https://github.com/ReVanced/revanced-patches/issues/5940)) ([f7f4a1b](https://github.com/ReVanced/revanced-patches/commit/f7f4a1b0f0186598266b41a2c6a781fdee49e440))
# [5.40.0-dev.11](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.10...v5.40.0-dev.11) (2025-09-20)
### Bug Fixes
* **YouTube - Spoof video streams:** Add stream audio selector disclaimer for Android Studio client ([a8a4107](https://github.com/ReVanced/revanced-patches/commit/a8a410708d50f34ac4bd2ca29bbbc3cde00bbf93))
# [5.40.0-dev.10](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.9...v5.40.0-dev.10) (2025-09-20)
### Bug Fixes
* **YouTube - Spoof video streams:** Add "Force original audio" disclaimer for Android Studio client ([f97d332](https://github.com/ReVanced/revanced-patches/commit/f97d33206b4c97244f0bd0c672c4b91eaf477b0b))
# [5.40.0-dev.9](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.8...v5.40.0-dev.9) (2025-09-20)
### Features
* **YouTube Music:** Support version `8.10.52` ([#5941](https://github.com/ReVanced/revanced-patches/issues/5941)) ([01c0f1b](https://github.com/ReVanced/revanced-patches/commit/01c0f1bd1ac6edb8aea758f88ffffcdea74a29b7))
# [5.40.0-dev.8](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.7...v5.40.0-dev.8) (2025-09-20)
### Features
* **YouTube:** Support version `20.14.43` ([#5940](https://github.com/ReVanced/revanced-patches/issues/5940)) ([f7f4a1b](https://github.com/ReVanced/revanced-patches/commit/f7f4a1b0f0186598266b41a2c6a781fdee49e440))
# [5.40.0-dev.7](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.6...v5.40.0-dev.7) (2025-09-20)
### Features
* **YouTube - Hide video action buttons:** Add "Hide comments" button ([db796fb](https://github.com/ReVanced/revanced-patches/commit/db796fb8830b813e1ed626d491c4a797171e69e7))
# [5.40.0-dev.6](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.5...v5.40.0-dev.6) (2025-09-20)
### Features
* **YouTube Music:** Add `Enable debugging` patch ([#5939](https://github.com/ReVanced/revanced-patches/issues/5939)) ([418f594](https://github.com/ReVanced/revanced-patches/commit/418f5945c213313f9a77cac9a5c326d89c754dfd))
# [5.40.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.4...v5.40.0-dev.5) (2025-09-20)
### Features
* **YouTube Music:** Add `Hide cast button` and `Navigation bar` patches ([#5934](https://github.com/ReVanced/revanced-patches/issues/5934)) ([651d358](https://github.com/ReVanced/revanced-patches/commit/651d3580967a252b57cbf4afbba02d6a4601ccfe))
# [5.40.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.3...v5.40.0-dev.4) (2025-09-20)
### Bug Fixes
* **Spoof video streams:** Resolve occasional playback stuttering ([5c7c8b5](https://github.com/ReVanced/revanced-patches/commit/5c7c8b536416ec53cd98f7d59d11850aa1b70f11))
# [5.40.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.2...v5.40.0-dev.3) (2025-09-19)
### Bug Fixes
* **Instagram - Limit feed to followed profiles:** Change patch to default off ([767f1e3](https://github.com/ReVanced/revanced-patches/commit/767f1e3695327bdbc4daea8b50a80d4c0a38456a))
# [5.40.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.40.0-dev.1...v5.40.0-dev.2) (2025-09-18)
### Features
* **Instagram:** Add `Limit feed to followed profiles` patch ([#5908](https://github.com/ReVanced/revanced-patches/issues/5908)) ([8ba9a19](https://github.com/ReVanced/revanced-patches/commit/8ba9a19ade24c5fe9bd6d4e49772b7663522780e))
# [5.40.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.39.1-dev.1...v5.40.0-dev.1) (2025-09-17)
### Features
* **Viber - Hide ads:** Support latest app target ([#5863](https://github.com/ReVanced/revanced-patches/issues/5863)) ([e6cce85](https://github.com/ReVanced/revanced-patches/commit/e6cce8554116df3c0ea6dbb7440c59c9e73d8334))
## [5.39.1-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.39.0...v5.39.1-dev.1) (2025-09-17)
### Bug Fixes
* **YouTube - Force original audio:** Show UI setting summary if spoofing to Android Studio ([b7026b7](https://github.com/ReVanced/revanced-patches/commit/b7026b70865bc44de07b30f84ba8b8b608930d5b))
# [5.39.0](https://github.com/ReVanced/revanced-patches/compare/v5.38.0...v5.39.0) (2025-09-17)
### Bug Fixes
* **YouTube - Spoof video streams:** Do not use Android Creator for livestreams ([cbe576b](https://github.com/ReVanced/revanced-patches/commit/cbe576bc384ef5f5ee2fa341147925ed0dff568b))
* **YouTube - Spoof video streams:** Show Android Studio in spoof stream menu ([c9f741e](https://github.com/ReVanced/revanced-patches/commit/c9f741e616c7acab0cd4558e02b0c4ec18392c10))
* **YouTube Music - Spoof video streams:** Remove iPadOS client ([7eeffd3](https://github.com/ReVanced/revanced-patches/commit/7eeffd3392c57555342173103d3a417c038d0970))
### Features
* **YouTube - Hide video action buttons:** Add "Hide Shop button" setting ([a84db7b](https://github.com/ReVanced/revanced-patches/commit/a84db7be7fde2e9bb3ac41aec709a1681e845fe1))
# [5.39.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.39.0-dev.1...v5.39.0-dev.2) (2025-09-17)
### Bug Fixes
* **YouTube - Spoof video streams:** Show Android Studio in spoof stream menu ([c9f741e](https://github.com/ReVanced/revanced-patches/commit/c9f741e616c7acab0cd4558e02b0c4ec18392c10))
# [5.39.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.38.1-dev.2...v5.39.0-dev.1) (2025-09-17)
### Features
* **YouTube - Hide video action buttons:** Add "Hide Shop button" setting ([a84db7b](https://github.com/ReVanced/revanced-patches/commit/a84db7be7fde2e9bb3ac41aec709a1681e845fe1))
## [5.38.1-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.38.1-dev.1...v5.38.1-dev.2) (2025-09-16)
### Bug Fixes
* **YouTube Music - Spoof video streams:** Remove iPadOS client ([7eeffd3](https://github.com/ReVanced/revanced-patches/commit/7eeffd3392c57555342173103d3a417c038d0970))
## [5.38.1-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.38.0...v5.38.1-dev.1) (2025-09-16)
### Bug Fixes
* **YouTube - Spoof video streams:** Do not use Android Creator for livestreams ([cbe576b](https://github.com/ReVanced/revanced-patches/commit/cbe576bc384ef5f5ee2fa341147925ed0dff568b))
# [5.38.0](https://github.com/ReVanced/revanced-patches/compare/v5.37.0...v5.38.0) (2025-09-16)
### Bug Fixes
* **Instagram - Hide navigation buttons:** Support v397.1.0.52.81 ([#5855](https://github.com/ReVanced/revanced-patches/issues/5855)) ([f11d1ef](https://github.com/ReVanced/revanced-patches/commit/f11d1ef9907082512f139d4ab0e2e9f707de7e48))
* **Spoof video streams:** Remove Android TV and iOS TV clients, add experimental VisionOS, add temporary fix for `Force original audio` to work with any spoof client ([#5861](https://github.com/ReVanced/revanced-patches/issues/5861)) ([abe3943](https://github.com/ReVanced/revanced-patches/commit/abe3943f98fd86dcd74c7e07cf65d3c7fc24fef9))
* **YouTube - Spoof video streams:** Show settings summary if `Force original audio` is enabled ([3776dda](https://github.com/ReVanced/revanced-patches/commit/3776dda710a7780717b7e6f2cdc1333ab67b92fc))
* **YouTube Music - Spoof video streams:** Fix playback issues when using a cellular network ([fa04c8e](https://github.com/ReVanced/revanced-patches/commit/fa04c8eecfbdd0b6ed082b464ca9032536d71762))
* **YouTube Music:** Use correct light/dark mode settings UI ([1475643](https://github.com/ReVanced/revanced-patches/commit/1475643f84e9ee4af2ba360a2274001ff1570dad))
### Features
* **Instagram:** Add `Hide explore feed` patch ([#5856](https://github.com/ReVanced/revanced-patches/issues/5856)) ([1d65887](https://github.com/ReVanced/revanced-patches/commit/1d65887e015a067196f5a84db486fff355c96596))
* **YouTube - Spoof video streams:** Add iPadOS client ([2726231](https://github.com/ReVanced/revanced-patches/commit/2726231404384d87f101d825e10a17c944e8f1bd))
* **YouTube Music:** Add `Settings` patch ([#5838](https://github.com/ReVanced/revanced-patches/issues/5838)) ([5e20bd8](https://github.com/ReVanced/revanced-patches/commit/5e20bd80f138d7ca94f18857194c46e489c435dc))
# [5.38.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v5.38.0-dev.4...v5.38.0-dev.5) (2025-09-16)
### Bug Fixes
* **YouTube Music:** Use correct light/dark mode settings UI ([1475643](https://github.com/ReVanced/revanced-patches/commit/1475643f84e9ee4af2ba360a2274001ff1570dad))
# [5.38.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.38.0-dev.3...v5.38.0-dev.4) (2025-09-16)
### Bug Fixes
* **YouTube - Spoof video streams:** Show settings summary if `Force original audio` is enabled ([3776dda](https://github.com/ReVanced/revanced-patches/commit/3776dda710a7780717b7e6f2cdc1333ab67b92fc))
# [5.38.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.38.0-dev.2...v5.38.0-dev.3) (2025-09-16)
### Features
* **YouTube - Spoof video streams:** Add iPadOS client ([2726231](https://github.com/ReVanced/revanced-patches/commit/2726231404384d87f101d825e10a17c944e8f1bd))
# [5.38.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.38.0-dev.1...v5.38.0-dev.2) (2025-09-16)
### Features
* **YouTube Music:** Add `Settings` patch ([#5838](https://github.com/ReVanced/revanced-patches/issues/5838)) ([5e20bd8](https://github.com/ReVanced/revanced-patches/commit/5e20bd80f138d7ca94f18857194c46e489c435dc))
# [5.38.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.37.1-dev.3...v5.38.0-dev.1) (2025-09-15)
### Features
* **Instagram:** Add `Hide explore feed` patch ([#5856](https://github.com/ReVanced/revanced-patches/issues/5856)) ([1d65887](https://github.com/ReVanced/revanced-patches/commit/1d65887e015a067196f5a84db486fff355c96596))
## [5.37.1-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.37.1-dev.2...v5.37.1-dev.3) (2025-09-15)
### Bug Fixes
* **Spoof video streams:** Remove Android TV and iOS TV clients, add experimental VisionOS, add temporary fix for `Force original audio` to work with any spoof client ([#5861](https://github.com/ReVanced/revanced-patches/issues/5861)) ([abe3943](https://github.com/ReVanced/revanced-patches/commit/abe3943f98fd86dcd74c7e07cf65d3c7fc24fef9))
## [5.37.1-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.37.1-dev.1...v5.37.1-dev.2) (2025-09-15)
### Bug Fixes
* **Instagram - Hide navigation buttons:** Support v397.1.0.52.81 ([#5855](https://github.com/ReVanced/revanced-patches/issues/5855)) ([f11d1ef](https://github.com/ReVanced/revanced-patches/commit/f11d1ef9907082512f139d4ab0e2e9f707de7e48))
## [5.37.1-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.37.0...v5.37.1-dev.1) (2025-09-15)
### Bug Fixes
* **YouTube Music - Spoof video streams:** Fix playback issues when using a cellular network ([fa04c8e](https://github.com/ReVanced/revanced-patches/commit/fa04c8eecfbdd0b6ed082b464ca9032536d71762))
# [5.37.0](https://github.com/ReVanced/revanced-patches/compare/v5.36.0...v5.37.0) (2025-09-15)
### Bug Fixes
* **Instagram - Hide navigation buttons:** Add constrain to known working version ([e6c79f1](https://github.com/ReVanced/revanced-patches/commit/e6c79f13834c83fef04e4dee5e628cb0b9a27765))
* Resolve patching with dev branch ([09b941a](https://github.com/ReVanced/revanced-patches/commit/09b941abf0e8029999565082b02a88b5de507ec4))
* **Spotify:** Remove broken `Spoof client` patch ([#5833](https://github.com/ReVanced/revanced-patches/issues/5833)) ([dcd4245](https://github.com/ReVanced/revanced-patches/commit/dcd42454bd5f87dddd720534f6120c4ef90063a3))
* **Viber - Hide ads:** Add constrain to known working version ([2db0948](https://github.com/ReVanced/revanced-patches/commit/2db0948beaf2b68391a1fe7f21e92d31c7df61e7))
* **YouTube Music - Spoof streaming data:** Fix audio playback stuttering ([#5839](https://github.com/ReVanced/revanced-patches/issues/5839)) ([2a85a3b](https://github.com/ReVanced/revanced-patches/commit/2a85a3b29092729ae16d1fd93803634ce5f08e95))
### Features
* **Viber:** Add `Hide ads` patch ([#5826](https://github.com/ReVanced/revanced-patches/issues/5826)) ([0abfab7](https://github.com/ReVanced/revanced-patches/commit/0abfab79d7cda15bf17c53679fbfffb021662649))
# [5.37.0-dev.6](https://github.com/ReVanced/revanced-patches/compare/v5.37.0-dev.5...v5.37.0-dev.6) (2025-09-15)
### Bug Fixes
* **Instagram - Hide navigation buttons:** Add constrain to known working version ([e6c79f1](https://github.com/ReVanced/revanced-patches/commit/e6c79f13834c83fef04e4dee5e628cb0b9a27765))
# [5.37.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v5.37.0-dev.4...v5.37.0-dev.5) (2025-09-15)
### Bug Fixes
* **Viber - Hide ads:** Add constrain to known working version ([2db0948](https://github.com/ReVanced/revanced-patches/commit/2db0948beaf2b68391a1fe7f21e92d31c7df61e7))
# [5.37.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.37.0-dev.3...v5.37.0-dev.4) (2025-09-14)
### Bug Fixes
* **YouTube Music - Spoof streaming data:** Fix audio playback stuttering ([#5839](https://github.com/ReVanced/revanced-patches/issues/5839)) ([2a85a3b](https://github.com/ReVanced/revanced-patches/commit/2a85a3b29092729ae16d1fd93803634ce5f08e95))
# [5.37.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.37.0-dev.2...v5.37.0-dev.3) (2025-09-14)
### Bug Fixes
* **Spotify:** Remove broken `Spoof client` patch ([#5833](https://github.com/ReVanced/revanced-patches/issues/5833)) ([dcd4245](https://github.com/ReVanced/revanced-patches/commit/dcd42454bd5f87dddd720534f6120c4ef90063a3))
# [5.37.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.37.0-dev.1...v5.37.0-dev.2) (2025-09-14)
### Bug Fixes
* Resolve patching with dev branch ([09b941a](https://github.com/ReVanced/revanced-patches/commit/09b941abf0e8029999565082b02a88b5de507ec4))
# [5.37.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.36.0...v5.37.0-dev.1) (2025-09-14)
### Features
* **Viber:** Add `Hide ads` patch ([#5826](https://github.com/ReVanced/revanced-patches/issues/5826)) ([0abfab7](https://github.com/ReVanced/revanced-patches/commit/0abfab79d7cda15bf17c53679fbfffb021662649))
# [5.36.0](https://github.com/ReVanced/revanced-patches/compare/v5.35.0...v5.36.0) (2025-09-14)
### Bug Fixes
* **Duolingo - Disable ads:** Support latest app target ([#5782](https://github.com/ReVanced/revanced-patches/issues/5782)) ([88b47ef](https://github.com/ReVanced/revanced-patches/commit/88b47ef414cd073ec3800258b32aceb6f383a411))
* **YouTube - Hide layout components:** Hide new type of Playable shelf ([8cd8e59](https://github.com/ReVanced/revanced-patches/commit/8cd8e59bbc3a878269276b8ae5f627b044d157f0))
* **YouTube Music:** Resolve playback issues, change recommended app target to `7.29.52` ([#5813](https://github.com/ReVanced/revanced-patches/issues/5813)) ([a53b00d](https://github.com/ReVanced/revanced-patches/commit/a53b00dd514dbe2b3406f3c1013a4f58a7f481c5))
### Features
* **YouTube - SponsorBlock:** Add 'Hook' segment category ([#5783](https://github.com/ReVanced/revanced-patches/issues/5783)) ([9d4aa5c](https://github.com/ReVanced/revanced-patches/commit/9d4aa5cd16a6f9e95cf7c626351b46b86ca80efe))
# [5.36.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.35.0...v5.36.0-dev.1) (2025-09-13)
### Bug Fixes
* **Duolingo - Disable ads:** Support latest app target ([#5782](https://github.com/ReVanced/revanced-patches/issues/5782)) ([88b47ef](https://github.com/ReVanced/revanced-patches/commit/88b47ef414cd073ec3800258b32aceb6f383a411))
* **YouTube - Hide layout components:** Hide new type of Playable shelf ([8cd8e59](https://github.com/ReVanced/revanced-patches/commit/8cd8e59bbc3a878269276b8ae5f627b044d157f0))
* **YouTube Music:** Resolve playback issues, change recommended app target to `7.29.52` ([#5813](https://github.com/ReVanced/revanced-patches/issues/5813)) ([a53b00d](https://github.com/ReVanced/revanced-patches/commit/a53b00dd514dbe2b3406f3c1013a4f58a7f481c5))
### Features
* **YouTube - SponsorBlock:** Add 'Hook' segment category ([#5783](https://github.com/ReVanced/revanced-patches/issues/5783)) ([9d4aa5c](https://github.com/ReVanced/revanced-patches/commit/9d4aa5cd16a6f9e95cf7c626351b46b86ca80efe))
# [5.36.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.35.0...v5.36.0-dev.1) (2025-09-13)
### Bug Fixes
* **Duolingo - Disable ads:** Support latest app target ([#5782](https://github.com/ReVanced/revanced-patches/issues/5782)) ([88b47ef](https://github.com/ReVanced/revanced-patches/commit/88b47ef414cd073ec3800258b32aceb6f383a411))
* **YouTube - Hide layout components:** Hide new type of Playable shelf ([8cd8e59](https://github.com/ReVanced/revanced-patches/commit/8cd8e59bbc3a878269276b8ae5f627b044d157f0))
* **YouTube Music:** Resolve playback issues, change recommended app target to `7.29.52` ([#5813](https://github.com/ReVanced/revanced-patches/issues/5813)) ([a53b00d](https://github.com/ReVanced/revanced-patches/commit/a53b00dd514dbe2b3406f3c1013a4f58a7f481c5))
### Features
* **YouTube - SponsorBlock:** Add 'Hook' segment category ([#5783](https://github.com/ReVanced/revanced-patches/issues/5783)) ([9d4aa5c](https://github.com/ReVanced/revanced-patches/commit/9d4aa5cd16a6f9e95cf7c626351b46b86ca80efe))
# [5.36.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.35.0...v5.36.0-dev.1) (2025-09-13)
### Bug Fixes
* **Duolingo - Disable ads:** Support latest app target ([#5782](https://github.com/ReVanced/revanced-patches/issues/5782)) ([88b47ef](https://github.com/ReVanced/revanced-patches/commit/88b47ef414cd073ec3800258b32aceb6f383a411))
* **YouTube - Hide layout components:** Hide new type of Playable shelf ([8cd8e59](https://github.com/ReVanced/revanced-patches/commit/8cd8e59bbc3a878269276b8ae5f627b044d157f0))
### Features
* **YouTube - SponsorBlock:** Add 'Hook' segment category ([#5783](https://github.com/ReVanced/revanced-patches/issues/5783)) ([9d4aa5c](https://github.com/ReVanced/revanced-patches/commit/9d4aa5cd16a6f9e95cf7c626351b46b86ca80efe))
# [5.36.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.35.0...v5.36.0-dev.1) (2025-09-12)
### Bug Fixes
* **Duolingo - Disable ads:** Support latest app target ([#5782](https://github.com/ReVanced/revanced-patches/issues/5782)) ([88b47ef](https://github.com/ReVanced/revanced-patches/commit/88b47ef414cd073ec3800258b32aceb6f383a411))
* **YouTube - Hide layout components:** Hide new type of Playable shelf ([8cd8e59](https://github.com/ReVanced/revanced-patches/commit/8cd8e59bbc3a878269276b8ae5f627b044d157f0))
### Features
* **YouTube - SponsorBlock:** Add 'Hook' segment category ([#5783](https://github.com/ReVanced/revanced-patches/issues/5783)) ([9d4aa5c](https://github.com/ReVanced/revanced-patches/commit/9d4aa5cd16a6f9e95cf7c626351b46b86ca80efe))
# [5.36.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.35.1-dev.1...v5.36.0-dev.1) (2025-09-12)
### Features
* **YouTube - SponsorBlock:** Add 'Hook' segment category ([#5783](https://github.com/ReVanced/revanced-patches/issues/5783)) ([2e042c4](https://github.com/ReVanced/revanced-patches/commit/2e042c4b3366fa3daf991d5560fcae991d00ad12))
## [5.35.1-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.35.0...v5.35.1-dev.1) (2025-09-11)
### Bug Fixes
* **Duolingo - Disable ads:** Support latest app target ([#5782](https://github.com/ReVanced/revanced-patches/issues/5782)) ([8491516](https://github.com/ReVanced/revanced-patches/commit/849151637389b8f399356d0d331bb74482f3f05d))
* **YouTube - Hide layout components:** Hide new type of Playable shelf ([3af4126](https://github.com/ReVanced/revanced-patches/commit/3af41265338ddaab52d009f53370c57abddd4599))
# [5.35.0](https://github.com/ReVanced/revanced-patches/compare/v5.34.0...v5.35.0) (2025-09-09)
### Bug Fixes
* **Instagram - Hide navigation buttons:** Fix Manager patching error ([0d10e94](https://github.com/ReVanced/revanced-patches/commit/0d10e94663283fac09f3efc57c9b9805c38c4e13))
* **Proton mail:** Constrain patches to last working app target ([21c34b9](https://github.com/ReVanced/revanced-patches/commit/21c34b908e07a97de8c31c7c828b44a8cc4739b6))
* Revert dependency updates to fix Manager pre-release patching ([4c7a1a8](https://github.com/ReVanced/revanced-patches/commit/4c7a1a8554c67797bf663e5230f566c5a9b229af))
* **Spotify - Unlock Premium:** Make compatible with latest versions again by fixing fingerprint ([#5684](https://github.com/ReVanced/revanced-patches/issues/5684)) ([30dcff1](https://github.com/ReVanced/revanced-patches/commit/30dcff13a56883efc499b71faadb403877cd1c67))
* **YouTube - Hide layout components:** Hide Playable shelf header ([fbb5046](https://github.com/ReVanced/revanced-patches/commit/fbb50463f0e3f533a278c5251cfbce59f09ce641))
### Features
* **BaconReader:** Add `Fix Redgifs API` patch ([#5761](https://github.com/ReVanced/revanced-patches/issues/5761)) ([08868c0](https://github.com/ReVanced/revanced-patches/commit/08868c00d3c4f1f37f4a77f333a03ca5a3259b59))
* **Boost/Sync for Reddit:** Add `Fix Redgifs` patch ([#5725](https://github.com/ReVanced/revanced-patches/issues/5725)) ([c5e8079](https://github.com/ReVanced/revanced-patches/commit/c5e8079eab08075a72078cd0fa79f3beb1f75d98))
* **Instagram:** Add `Hide navigation buttons` patch ([#5678](https://github.com/ReVanced/revanced-patches/issues/5678)) ([415cf0f](https://github.com/ReVanced/revanced-patches/commit/415cf0fb5b9b3dcaf4592943a69eea1c10447b07))
* **Instagram:** Add `Hide Stories from Home` patch ([#5756](https://github.com/ReVanced/revanced-patches/issues/5756)) ([3ae3251](https://github.com/ReVanced/revanced-patches/commit/3ae3251dc0317b6ced136fe9aa14be369642f203))
# [5.35.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v5.35.0-dev.4...v5.35.0-dev.5) (2025-09-06)
### Features
* **BaconReader:** Add `Fix Redgifs API` patch ([#5761](https://github.com/ReVanced/revanced-patches/issues/5761)) ([08868c0](https://github.com/ReVanced/revanced-patches/commit/08868c00d3c4f1f37f4a77f333a03ca5a3259b59))
* **Instagram:** Add `Hide Stories from Home` patch ([#5756](https://github.com/ReVanced/revanced-patches/issues/5756)) ([3ae3251](https://github.com/ReVanced/revanced-patches/commit/3ae3251dc0317b6ced136fe9aa14be369642f203))
# [5.35.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.35.0-dev.3...v5.35.0-dev.4) (2025-09-04)
### Features
* **Boost/Sync for Reddit:** Add `Fix Redgifs` patch ([#5725](https://github.com/ReVanced/revanced-patches/issues/5725)) ([c5e8079](https://github.com/ReVanced/revanced-patches/commit/c5e8079eab08075a72078cd0fa79f3beb1f75d98))
# [5.35.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.35.0-dev.2...v5.35.0-dev.3) (2025-09-04)
### Bug Fixes
* **Instagram - Hide navigation buttons:** Fix Manager patching error ([0d10e94](https://github.com/ReVanced/revanced-patches/commit/0d10e94663283fac09f3efc57c9b9805c38c4e13))
# [5.35.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.35.0-dev.1...v5.35.0-dev.2) (2025-09-04)
### Bug Fixes
* Revert dependency updates to fix Manager pre-release patching ([4c7a1a8](https://github.com/ReVanced/revanced-patches/commit/4c7a1a8554c67797bf663e5230f566c5a9b229af))
# [5.35.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.34.1-dev.3...v5.35.0-dev.1) (2025-09-03)

8
adsfund.json Normal file
View File

@@ -0,0 +1,8 @@
{
"info": "This is verification file for ads.fund project",
"project": {
"name": "Revanced Patches",
"walletAddress": "0x7ab4091e00363654bf84B34151225742cd92FCE5",
"tokenAddress": "0xadf325f255083a3f3d9a9d01ffb3db52a148d802"
}
}

View File

@@ -0,0 +1,5 @@
dependencies {
compileOnly(project(":extensions:shared:library"))
compileOnly(libs.annotation)
compileOnly(libs.okhttp)
}

View File

@@ -0,0 +1 @@
<manifest/>

View File

@@ -0,0 +1,22 @@
package app.revanced.extension.baconreader;
import app.revanced.extension.shared.fixes.redgifs.BaseFixRedgifsApiPatch;
import okhttp3.OkHttpClient;
/**
* @noinspection unused
*/
public class FixRedgifsApiPatch extends BaseFixRedgifsApiPatch {
static {
INSTANCE = new FixRedgifsApiPatch();
}
public String getDefaultUserAgent() {
// BaconReader uses a static user agent for Redgifs API calls
return "BaconReader";
}
public static OkHttpClient install(OkHttpClient.Builder builder) {
return builder.addInterceptor(INSTANCE).build();
}
}

View File

@@ -1,4 +1,6 @@
dependencies {
compileOnly(project(":extensions:shared:library"))
compileOnly(project(":extensions:boostforreddit:stub"))
compileOnly(libs.annotation)
compileOnly(libs.okhttp)
}

View File

@@ -0,0 +1,22 @@
package app.revanced.extension.boostforreddit;
import app.revanced.extension.shared.fixes.redgifs.BaseFixRedgifsApiPatch;
import okhttp3.OkHttpClient;
/**
* @noinspection unused
*/
public class FixRedgifsApiPatch extends BaseFixRedgifsApiPatch {
static {
INSTANCE = new FixRedgifsApiPatch();
}
public String getDefaultUserAgent() {
// Boost uses a static user agent for Redgifs API calls
return "Boost";
}
public static OkHttpClient createClient() {
return new OkHttpClient.Builder().addInterceptor(INSTANCE).build();
}
}

View File

@@ -0,0 +1,3 @@
dependencies {
compileOnly(project(":extensions:shared:library"))
}

View File

@@ -0,0 +1 @@
<manifest/>

View File

@@ -0,0 +1,18 @@
package app.revanced.extension.instagram.feed;
import java.util.HashMap;
import java.util.Map;
@SuppressWarnings("unused")
public class LimitFeedToFollowedProfiles {
/**
* Injection point.
*/
public static Map<String, String> setFollowingHeader(Map<String, String> requestHeaderMap) {
// Create new map as original is unmodifiable.
Map<String, String> patchedRequestHeaderMap = new HashMap<>(requestHeaderMap);
patchedRequestHeaderMap.put("pagination_source", "following");
return patchedRequestHeaderMap;
}
}

View File

@@ -1,3 +1,9 @@
dependencies {
compileOnly(project(":extensions:shared:library"))
compileOnly(project(":extensions:youtube:stub"))
compileOnly(libs.annotation)
}
android {
defaultConfig {
minSdk = 26

View File

@@ -0,0 +1,24 @@
package app.revanced.extension.music.patches;
import static app.revanced.extension.shared.Utils.hideViewBy0dpUnderCondition;
import android.view.View;
import app.revanced.extension.music.settings.Settings;
@SuppressWarnings("unused")
public class HideCastButtonPatch {
/**
* Injection point
*/
public static int hideCastButton(int original) {
return Settings.HIDE_CAST_BUTTON.get() ? View.GONE : original;
}
/**
* Injection point
*/
public static void hideCastButton(View view) {
hideViewBy0dpUnderCondition(Settings.HIDE_CAST_BUTTON.get(), view);
}
}

View File

@@ -0,0 +1,14 @@
package app.revanced.extension.music.patches;
import app.revanced.extension.music.settings.Settings;
@SuppressWarnings("unused")
public class HideCategoryBarPatch {
/**
* Injection point
*/
public static boolean hideCategoryBar() {
return Settings.HIDE_CATEGORY_BAR.get();
}
}

View File

@@ -0,0 +1,14 @@
package app.revanced.extension.music.patches;
import app.revanced.extension.music.settings.Settings;
@SuppressWarnings("unused")
public class HideGetPremiumPatch {
/**
* Injection point
*/
public static boolean hideGetPremiumLabel() {
return Settings.HIDE_GET_PREMIUM_LABEL.get();
}
}

View File

@@ -0,0 +1,17 @@
package app.revanced.extension.music.patches;
import app.revanced.extension.music.settings.Settings;
@SuppressWarnings("unused")
public class HideVideoAdsPatch {
/**
* Injection point
*/
public static boolean showVideoAds(boolean original) {
if (Settings.HIDE_VIDEO_ADS.get()) {
return false;
}
return original;
}
}

View File

@@ -0,0 +1,74 @@
package app.revanced.extension.music.patches;
import static app.revanced.extension.shared.Utils.hideViewUnderCondition;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import app.revanced.extension.music.settings.Settings;
@SuppressWarnings("unused")
public class NavigationBarPatch {
@NonNull
private static String lastYTNavigationEnumName = "";
public static void setLastAppNavigationEnum(@Nullable Enum<?> ytNavigationEnumName) {
if (ytNavigationEnumName != null) {
lastYTNavigationEnumName = ytNavigationEnumName.name();
}
}
public static void hideNavigationLabel(TextView textview) {
hideViewUnderCondition(Settings.HIDE_NAVIGATION_BAR_LABEL.get(), textview);
}
public static void hideNavigationButton(@NonNull View view) {
// Hide entire navigation bar.
if (Settings.HIDE_NAVIGATION_BAR.get() && view.getParent() != null) {
hideViewUnderCondition(true, (View) view.getParent());
return;
}
// Hide navigation buttons based on their type.
for (NavigationButton button : NavigationButton.values()) {
if (button.ytEnumNames.equals(lastYTNavigationEnumName)) {
hideViewUnderCondition(button.hidden, view);
break;
}
}
}
private enum NavigationButton {
HOME(
"TAB_HOME",
Settings.HIDE_NAVIGATION_BAR_HOME_BUTTON.get()
),
SAMPLES(
"TAB_SAMPLES",
Settings.HIDE_NAVIGATION_BAR_SAMPLES_BUTTON.get()
),
EXPLORE(
"TAB_EXPLORE",
Settings.HIDE_NAVIGATION_BAR_EXPLORE_BUTTON.get()
),
LIBRARY(
"LIBRARY_MUSIC",
Settings.HIDE_NAVIGATION_BAR_LIBRARY_BUTTON.get()
),
UPGRADE(
"TAB_MUSIC_PREMIUM",
Settings.HIDE_NAVIGATION_BAR_UPGRADE_BUTTON.get()
);
private final String ytEnumNames;
private final boolean hidden;
NavigationButton(@NonNull String ytEnumNames, boolean hidden) {
this.ytEnumNames = ytEnumNames;
this.hidden = hidden;
}
}
}

View File

@@ -0,0 +1,14 @@
package app.revanced.extension.music.patches;
import app.revanced.extension.music.settings.Settings;
@SuppressWarnings("unused")
public class PermanentRepeatPatch {
/**
* Injection point
*/
public static boolean permanentRepeat() {
return Settings.PERMANENT_REPEAT.get();
}
}

View File

@@ -0,0 +1,28 @@
package app.revanced.extension.music.patches.spoof;
import static app.revanced.extension.music.settings.Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE;
import static app.revanced.extension.shared.spoof.ClientType.ANDROID_VR_1_43_32;
import static app.revanced.extension.shared.spoof.ClientType.ANDROID_VR_1_61_48;
import static app.revanced.extension.shared.spoof.ClientType.VISIONOS;
import java.util.List;
import app.revanced.extension.shared.spoof.ClientType;
@SuppressWarnings("unused")
public class SpoofVideoStreamsPatch {
/**
* Injection point.
*/
public static void setClientOrderToUse() {
List<ClientType> availableClients = List.of(
ANDROID_VR_1_43_32,
ANDROID_VR_1_61_48,
VISIONOS
);
app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch.setClientsToUse(
availableClients, SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get());
}
}

View File

@@ -0,0 +1,126 @@
package app.revanced.extension.music.settings;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.preference.PreferenceFragment;
import android.view.View;
import android.widget.Toolbar;
import app.revanced.extension.music.settings.preference.MusicPreferenceFragment;
import app.revanced.extension.music.settings.search.MusicSearchViewController;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.BaseActivityHook;
/**
* Hooks GoogleApiActivity to inject a custom {@link MusicPreferenceFragment} with a toolbar and search.
*/
public class MusicActivityHook extends BaseActivityHook {
@SuppressLint("StaticFieldLeak")
public static MusicSearchViewController searchViewController;
/**
* Injection point.
*/
@SuppressWarnings("unused")
public static void initialize(Activity parentActivity) {
// Must touch the Music settings to ensure the class is loaded and
// the values can be found when setting the UI preferences.
// Logging anything under non debug ensures this is set.
Logger.printInfo(() -> "Permanent repeat enabled: " + Settings.PERMANENT_REPEAT.get());
// YT Music always uses dark mode.
Utils.setIsDarkModeEnabled(true);
BaseActivityHook.initialize(new MusicActivityHook(), parentActivity);
}
/**
* Sets the fixed theme for the activity.
*/
@Override
protected void customizeActivityTheme(Activity activity) {
// Override the default YouTube Music theme to increase start padding of list items.
// Custom style located in resources/music/values/style.xml
activity.setTheme(Utils.getResourceIdentifierOrThrow(
"Theme.ReVanced.YouTubeMusic.Settings", "style"));
}
/**
* Returns the resource ID for the YouTube Music settings layout.
*/
@Override
protected int getContentViewResourceId() {
return LAYOUT_REVANCED_SETTINGS_WITH_TOOLBAR;
}
/**
* Returns the fixed background color for the toolbar.
*/
@Override
protected int getToolbarBackgroundColor() {
return Utils.getResourceColor("ytm_color_black");
}
/**
* Returns the navigation icon with a color filter applied.
*/
@Override
protected Drawable getNavigationIcon() {
Drawable navigationIcon = MusicPreferenceFragment.getBackButtonDrawable();
navigationIcon.setColorFilter(Utils.getAppForegroundColor(), PorterDuff.Mode.SRC_IN);
return navigationIcon;
}
/**
* Returns the click listener that finishes the activity when the navigation icon is clicked.
*/
@Override
protected View.OnClickListener getNavigationClickListener(Activity activity) {
return view -> {
if (searchViewController != null && searchViewController.isSearchActive()) {
searchViewController.closeSearch();
} else {
activity.finish();
}
};
}
/**
* Adds search view components to the toolbar for {@link MusicPreferenceFragment}.
*
* @param activity The activity hosting the toolbar.
* @param toolbar The configured toolbar.
* @param fragment The PreferenceFragment associated with the activity.
*/
@Override
protected void onPostToolbarSetup(Activity activity, Toolbar toolbar, PreferenceFragment fragment) {
if (fragment instanceof MusicPreferenceFragment) {
searchViewController = MusicSearchViewController.addSearchViewComponents(
activity, toolbar, (MusicPreferenceFragment) fragment);
}
}
/**
* Creates a new {@link MusicPreferenceFragment} for the activity.
*/
@Override
protected PreferenceFragment createPreferenceFragment() {
return new MusicPreferenceFragment();
}
/**
* Injection point.
* <p>
* Overrides {@link Activity#finish()} of the injection Activity.
*
* @return if the original activity finish method should be allowed to run.
*/
@SuppressWarnings("unused")
public static boolean handleFinish() {
return MusicSearchViewController.handleFinish(searchViewController);
}
}

View File

@@ -0,0 +1,35 @@
package app.revanced.extension.music.settings;
import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
import static app.revanced.extension.shared.settings.Setting.parent;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.settings.BooleanSetting;
import app.revanced.extension.shared.settings.EnumSetting;
import app.revanced.extension.shared.spoof.ClientType;
public class Settings extends BaseSettings {
// Ads
public static final BooleanSetting HIDE_VIDEO_ADS = new BooleanSetting("revanced_music_hide_video_ads", TRUE, true);
public static final BooleanSetting HIDE_GET_PREMIUM_LABEL = new BooleanSetting("revanced_music_hide_get_premium_label", TRUE, true);
// General
public static final BooleanSetting HIDE_CAST_BUTTON = new BooleanSetting("revanced_music_hide_cast_button", TRUE, false);
public static final BooleanSetting HIDE_CATEGORY_BAR = new BooleanSetting("revanced_music_hide_category_bar", FALSE, true);
public static final BooleanSetting HIDE_NAVIGATION_BAR_HOME_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_home_button", FALSE, true);
public static final BooleanSetting HIDE_NAVIGATION_BAR_SAMPLES_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_samples_button", FALSE, true);
public static final BooleanSetting HIDE_NAVIGATION_BAR_EXPLORE_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_explore_button", FALSE, true);
public static final BooleanSetting HIDE_NAVIGATION_BAR_LIBRARY_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_library_button", FALSE, true);
public static final BooleanSetting HIDE_NAVIGATION_BAR_UPGRADE_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_upgrade_button", TRUE, true);
public static final BooleanSetting HIDE_NAVIGATION_BAR = new BooleanSetting("revanced_music_hide_navigation_bar", FALSE, true);
public static final BooleanSetting HIDE_NAVIGATION_BAR_LABEL = new BooleanSetting("revanced_music_hide_navigation_bar_labels", FALSE, true);
// Player
public static final BooleanSetting PERMANENT_REPEAT = new BooleanSetting("revanced_music_play_permanent_repeat", FALSE, true);
// Miscellaneous
public static final EnumSetting<ClientType> SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_video_streams_client_type",
ClientType.ANDROID_VR_1_43_32, true, parent(SPOOF_VIDEO_STREAMS));
}

View File

@@ -0,0 +1,80 @@
package app.revanced.extension.music.settings.preference;
import android.app.Dialog;
import android.preference.PreferenceScreen;
import android.widget.Toolbar;
import app.revanced.extension.music.settings.MusicActivityHook;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.preference.ToolbarPreferenceFragment;
/**
* Preference fragment for ReVanced settings.
*/
@SuppressWarnings("deprecation")
public class MusicPreferenceFragment extends ToolbarPreferenceFragment {
/**
* The main PreferenceScreen used to display the current set of preferences.
*/
private PreferenceScreen preferenceScreen;
/**
* Initializes the preference fragment.
*/
@Override
protected void initialize() {
super.initialize();
try {
preferenceScreen = getPreferenceScreen();
Utils.sortPreferenceGroups(preferenceScreen);
setPreferenceScreenToolbar(preferenceScreen);
} catch (Exception ex) {
Logger.printException(() -> "initialize failure", ex);
}
}
/**
* Called when the fragment starts.
*/
@Override
public void onStart() {
super.onStart();
try {
// Initialize search controller if needed
if (MusicActivityHook.searchViewController != null) {
// Trigger search data collection after fragment is ready.
MusicActivityHook.searchViewController.initializeSearchData();
}
} catch (Exception ex) {
Logger.printException(() -> "onStart failure", ex);
}
}
/**
* Sets toolbar for all nested preference screens.
*/
@Override
protected void customizeToolbar(Toolbar toolbar) {
MusicActivityHook.setToolbarLayoutParams(toolbar);
}
/**
* Perform actions after toolbar setup.
*/
@Override
protected void onPostToolbarSetup(Toolbar toolbar, Dialog preferenceScreenDialog) {
if (MusicActivityHook.searchViewController != null
&& MusicActivityHook.searchViewController.isSearchActive()) {
toolbar.post(() -> MusicActivityHook.searchViewController.closeSearch());
}
}
/**
* Returns the preference screen for external access by SearchViewController.
*/
public PreferenceScreen getPreferenceScreenForSearch() {
return preferenceScreen;
}
}

View File

@@ -0,0 +1,28 @@
package app.revanced.extension.music.settings.search;
import android.content.Context;
import android.preference.PreferenceScreen;
import app.revanced.extension.shared.settings.search.BaseSearchResultsAdapter;
import app.revanced.extension.shared.settings.search.BaseSearchViewController;
import app.revanced.extension.shared.settings.search.BaseSearchResultItem;
import java.util.List;
/**
* Music-specific search results adapter.
*/
@SuppressWarnings("deprecation")
public class MusicSearchResultsAdapter extends BaseSearchResultsAdapter {
public MusicSearchResultsAdapter(Context context, List<BaseSearchResultItem> items,
BaseSearchViewController.BasePreferenceFragment fragment,
BaseSearchViewController searchViewController) {
super(context, items, fragment, searchViewController);
}
@Override
protected PreferenceScreen getMainPreferenceScreen() {
return fragment.getPreferenceScreenForSearch();
}
}

View File

@@ -0,0 +1,71 @@
package app.revanced.extension.music.settings.search;
import android.app.Activity;
import android.preference.Preference;
import android.preference.PreferenceScreen;
import android.view.View;
import android.widget.Toolbar;
import app.revanced.extension.music.settings.preference.MusicPreferenceFragment;
import app.revanced.extension.shared.settings.search.*;
/**
* Music-specific search view controller implementation.
*/
@SuppressWarnings("deprecation")
public class MusicSearchViewController extends BaseSearchViewController {
public static MusicSearchViewController addSearchViewComponents(Activity activity, Toolbar toolbar,
MusicPreferenceFragment fragment) {
return new MusicSearchViewController(activity, toolbar, fragment);
}
private MusicSearchViewController(Activity activity, Toolbar toolbar, MusicPreferenceFragment fragment) {
super(activity, toolbar, new PreferenceFragmentAdapter(fragment));
}
@Override
protected BaseSearchResultsAdapter createSearchResultsAdapter() {
return new MusicSearchResultsAdapter(activity, filteredSearchItems, fragment, this);
}
@Override
protected boolean isSpecialPreferenceGroup(Preference preference) {
// Music doesn't have SponsorBlock, so no special groups.
return false;
}
@Override
protected void setupSpecialPreferenceListeners(BaseSearchResultItem item) {
// Music doesn't have special preferences.
// This method can be empty or handle music-specific preferences if any.
}
// Static method for handling Activity finish
public static boolean handleFinish(MusicSearchViewController searchViewController) {
if (searchViewController != null && searchViewController.isSearchActive()) {
searchViewController.closeSearch();
return true;
}
return false;
}
// Adapter to wrap MusicPreferenceFragment to BasePreferenceFragment interface.
private record PreferenceFragmentAdapter(MusicPreferenceFragment fragment) implements BasePreferenceFragment {
@Override
public PreferenceScreen getPreferenceScreenForSearch() {
return fragment.getPreferenceScreenForSearch();
}
@Override
public View getView() {
return fragment.getView();
}
@Override
public Activity getActivity() {
return fragment.getActivity();
}
}
}

View File

@@ -1,27 +0,0 @@
package app.revanced.extension.music.spoof;
/**
* @noinspection unused
*/
public class SpoofClientPatch {
private static final int CLIENT_TYPE_ID = 26;
private static final String CLIENT_VERSION = "6.21";
private static final String DEVICE_MODEL = "iPhone16,2";
private static final String OS_VERSION = "17.7.2.21H221";
public static int getClientId() {
return CLIENT_TYPE_ID;
}
public static String getClientVersion() {
return CLIENT_VERSION;
}
public static String getClientModel() {
return DEVICE_MODEL;
}
public static String getOsVersion() {
return OS_VERSION;
}
}

View File

@@ -1,3 +1,4 @@
dependencies {
implementation(project(":extensions:shared:library"))
compileOnly(libs.okhttp)
}

View File

@@ -18,4 +18,5 @@ android {
dependencies {
compileOnly(libs.annotation)
compileOnly(libs.okhttp)
}

View File

@@ -1,6 +1,4 @@
package app.revanced.extension.youtube;
import androidx.annotation.NonNull;
package app.revanced.extension.shared;
import java.nio.charset.StandardCharsets;
@@ -39,7 +37,7 @@ public final class ByteTrieSearch extends TrieSearch<byte[]> {
return replacement;
}
public ByteTrieSearch(@NonNull byte[]... patterns) {
public ByteTrieSearch(byte[]... patterns) {
super(new ByteTrieNode(), patterns);
}
}

View File

@@ -27,6 +27,7 @@ import java.util.Locale;
import app.revanced.extension.shared.requests.Requester;
import app.revanced.extension.shared.requests.Route;
import app.revanced.extension.shared.ui.CustomDialog;
@SuppressWarnings("unused")
public class GmsCoreSupport {
@@ -80,17 +81,17 @@ public class GmsCoreSupport {
// Otherwise, if device is in dark mode the dialog is shown with wrong color scheme.
Utils.runOnMainThreadDelayed(() -> {
// Create the custom dialog.
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context,
str("gms_core_dialog_title"), // Title.
str(dialogMessageRef), // Message.
null, // No EditText.
str(positiveButtonTextRef), // OK button text.
str(dialogMessageRef), // Message.
null, // No EditText.
str(positiveButtonTextRef), // OK button text.
() -> onPositiveClickListener.onClick(null, 0), // Convert DialogInterface.OnClickListener to Runnable.
null, // No Cancel button action.
null, // No Neutral button text.
null, // No Neutral button action.
true // Dismiss dialog when onNeutralClick.
null, // No Cancel button action.
null, // No Neutral button text.
null, // No Neutral button action.
true // Dismiss dialog when onNeutralClick.
);
Dialog dialog = dialogPair.first;

View File

@@ -1,6 +1,4 @@
package app.revanced.extension.youtube;
import androidx.annotation.NonNull;
package app.revanced.extension.shared;
/**
* Text pattern searching using a prefix tree (trie).
@@ -28,7 +26,7 @@ public final class StringTrieSearch extends TrieSearch<String> {
}
}
public StringTrieSearch(@NonNull String... patterns) {
public StringTrieSearch(String... patterns) {
super(new StringTrieNode(), patterns);
}
}

View File

@@ -1,6 +1,5 @@
package app.revanced.extension.youtube;
package app.revanced.extension.shared;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
@@ -57,11 +56,13 @@ public abstract class TrieSearch<T> {
if (searchTextLength - searchTextIndex < patternLength - patternStartIndex) {
return false; // Remaining search text is shorter than the remaining leaf pattern and they cannot match.
}
for (int i = searchTextIndex, j = patternStartIndex; j < patternLength; i++, j++) {
if (enclosingNode.getCharValue(searchText, i) != enclosingNode.getCharValue(pattern, j)) {
return false;
}
}
return callback == null || callback.patternMatched(searchText,
searchTextIndex - patternStartIndex, patternLength, callbackParameter);
}
@@ -136,7 +137,7 @@ public abstract class TrieSearch<T> {
* @param patternLength Length of the pattern.
* @param callback Callback, where a value of NULL indicates to always accept a pattern match.
*/
private void addPattern(@NonNull T pattern, int patternIndex, int patternLength,
private void addPattern(T pattern, int patternIndex, int patternLength,
@Nullable TriePatternMatchedCallback<T> callback) {
if (patternIndex == patternLength) { // Reached the end of the pattern.
if (endOfPatternCallback == null) {
@@ -145,6 +146,7 @@ public abstract class TrieSearch<T> {
endOfPatternCallback.add(callback);
return;
}
if (leaf != null) {
// Reached end of the graph and a leaf exist.
// Recursively call back into this method and push the existing leaf down 1 level.
@@ -159,6 +161,7 @@ public abstract class TrieSearch<T> {
leaf = new TrieCompressedPath<>(pattern, patternIndex, patternLength, callback);
return;
}
final char character = getCharValue(pattern, patternIndex);
final int arrayIndex = hashIndexForTableSize(children.length, character);
TrieNode<T> child = children[arrayIndex];
@@ -183,6 +186,7 @@ public abstract class TrieSearch<T> {
//noinspection unchecked
TrieNode<T>[] replacement = new TrieNode[replacementArraySize];
addNodeToArray(replacement, child);
boolean collision = false;
for (TrieNode<T> existingChild : children) {
if (existingChild != null) {
@@ -195,6 +199,7 @@ public abstract class TrieSearch<T> {
if (collision) {
continue;
}
children = replacement;
return;
}
@@ -234,6 +239,7 @@ public abstract class TrieSearch<T> {
if (leaf != null && leaf.matches(startNode, searchText, searchTextEndIndex, searchTextIndex, callbackParameter)) {
return true; // Leaf exists and it matched the search text.
}
List<TriePatternMatchedCallback<T>> endOfPatternCallback = node.endOfPatternCallback;
if (endOfPatternCallback != null) {
final int matchStartIndex = searchTextIndex - currentMatchLength;
@@ -246,6 +252,7 @@ public abstract class TrieSearch<T> {
}
}
}
TrieNode<T>[] children = node.children;
if (children == null) {
return false; // Reached a graph end point and there's no further patterns to search.
@@ -278,9 +285,11 @@ public abstract class TrieSearch<T> {
if (leaf != null) {
numberOfPointers += 4; // Number of fields in leaf node.
}
if (endOfPatternCallback != null) {
numberOfPointers += endOfPatternCallback.size();
}
if (children != null) {
numberOfPointers += children.length;
for (TrieNode<T> child : children) {
@@ -308,13 +317,13 @@ public abstract class TrieSearch<T> {
private final List<T> patterns = new ArrayList<>();
@SafeVarargs
TrieSearch(@NonNull TrieNode<T> root, @NonNull T... patterns) {
TrieSearch(TrieNode<T> root, T... patterns) {
this.root = Objects.requireNonNull(root);
addPatterns(patterns);
}
@SafeVarargs
public final void addPatterns(@NonNull T... patterns) {
public final void addPatterns(T... patterns) {
for (T pattern : patterns) {
addPattern(pattern);
}
@@ -325,7 +334,7 @@ public abstract class TrieSearch<T> {
*
* @param pattern Pattern to add. Calling this with a zero length pattern does nothing.
*/
public void addPattern(@NonNull T pattern) {
public void addPattern(T pattern) {
addPattern(pattern, root.getTextLength(pattern), null);
}
@@ -333,31 +342,31 @@ public abstract class TrieSearch<T> {
* @param pattern Pattern to add. Calling this with a zero length pattern does nothing.
* @param callback Callback to determine if searching should halt when a match is found.
*/
public void addPattern(@NonNull T pattern, @NonNull TriePatternMatchedCallback<T> callback) {
public void addPattern(T pattern, TriePatternMatchedCallback<T> callback) {
addPattern(pattern, root.getTextLength(pattern), Objects.requireNonNull(callback));
}
void addPattern(@NonNull T pattern, int patternLength, @Nullable TriePatternMatchedCallback<T> callback) {
void addPattern(T pattern, int patternLength, @Nullable TriePatternMatchedCallback<T> callback) {
if (patternLength == 0) return; // Nothing to match
patterns.add(pattern);
root.addPattern(pattern, 0, patternLength, callback);
}
public final boolean matches(@NonNull T textToSearch) {
public final boolean matches(T textToSearch) {
return matches(textToSearch, 0);
}
public boolean matches(@NonNull T textToSearch, @NonNull Object callbackParameter) {
public boolean matches(T textToSearch, Object callbackParameter) {
return matches(textToSearch, 0, root.getTextLength(textToSearch),
Objects.requireNonNull(callbackParameter));
}
public boolean matches(@NonNull T textToSearch, int startIndex) {
public boolean matches(T textToSearch, int startIndex) {
return matches(textToSearch, startIndex, root.getTextLength(textToSearch));
}
public final boolean matches(@NonNull T textToSearch, int startIndex, int endIndex) {
public final boolean matches(T textToSearch, int startIndex, int endIndex) {
return matches(textToSearch, startIndex, endIndex, null);
}
@@ -370,11 +379,11 @@ public abstract class TrieSearch<T> {
* @param callbackParameter Optional parameter passed to the callbacks.
* @return If any pattern matched, and it's callback halted searching.
*/
public boolean matches(@NonNull T textToSearch, int startIndex, int endIndex, @Nullable Object callbackParameter) {
public boolean matches(T textToSearch, int startIndex, int endIndex, @Nullable Object callbackParameter) {
return matches(textToSearch, root.getTextLength(textToSearch), startIndex, endIndex, callbackParameter);
}
private boolean matches(@NonNull T textToSearch, int textToSearchLength, int startIndex, int endIndex,
private boolean matches(T textToSearch, int textToSearchLength, int startIndex, int endIndex,
@Nullable Object callbackParameter) {
if (endIndex > textToSearchLength) {
throw new IllegalArgumentException("endIndex: " + endIndex

View File

@@ -4,6 +4,8 @@ import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.Dialog;
import android.app.DialogFragment;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
@@ -12,9 +14,6 @@ import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.ShapeDrawable;
import android.graphics.drawable.shapes.RoundRectShape;
import android.net.ConnectivityManager;
import android.os.Build;
import android.os.Bundle;
@@ -23,9 +22,6 @@ import android.os.Looper;
import android.preference.Preference;
import android.preference.PreferenceGroup;
import android.preference.PreferenceScreen;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.util.DisplayMetrics;
import android.util.Pair;
import android.util.TypedValue;
@@ -37,13 +33,9 @@ import android.view.Window;
import android.view.WindowManager;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.RelativeLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import android.widget.Toolbar;
@@ -69,6 +61,7 @@ import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.settings.BooleanSetting;
import app.revanced.extension.shared.settings.preference.ReVancedAboutPreference;
@SuppressWarnings("NewApi")
public class Utils {
@SuppressLint("StaticFieldLeak")
@@ -116,7 +109,7 @@ public class Utils {
}
/**
* @return The version name of the app, such as 19.11.43
* @return The version name of the app, such as 20.13.41
*/
public static String getAppVersionName() {
if (versionName == null) {
@@ -278,41 +271,63 @@ public class Utils {
* @return zero, if the resource is not found.
*/
@SuppressLint("DiscouragedApi")
public static int getResourceIdentifier(Context context, String resourceIdentifierName, String type) {
public static int getResourceIdentifier(Context context, String resourceIdentifierName, @Nullable String type) {
return context.getResources().getIdentifier(resourceIdentifierName, type, context.getPackageName());
}
public static int getResourceIdentifierOrThrow(Context context, String resourceIdentifierName, @Nullable String type) {
final int resourceId = getResourceIdentifier(context, resourceIdentifierName, type);
if (resourceId == 0) {
throw new Resources.NotFoundException("No resource id exists with name: " + resourceIdentifierName
+ " type: " + type);
}
return resourceId;
}
/**
* @return zero, if the resource is not found.
* @see #getResourceIdentifierOrThrow(String, String)
*/
public static int getResourceIdentifier(String resourceIdentifierName, String type) {
public static int getResourceIdentifier(String resourceIdentifierName, @Nullable String type) {
return getResourceIdentifier(getContext(), resourceIdentifierName, type);
}
/**
* @return The resource identifier, or throws an exception if not found.
*/
public static int getResourceIdentifierOrThrow(String resourceIdentifierName, @Nullable String type) {
final int resourceId = getResourceIdentifier(getContext(), resourceIdentifierName, type);
if (resourceId == 0) {
throw new Resources.NotFoundException("No resource id exists with name: " + resourceIdentifierName
+ " type: " + type);
}
return resourceId;
}
public static int getResourceInteger(String resourceIdentifierName) throws Resources.NotFoundException {
return getContext().getResources().getInteger(getResourceIdentifier(resourceIdentifierName, "integer"));
return getContext().getResources().getInteger(getResourceIdentifierOrThrow(resourceIdentifierName, "integer"));
}
public static Animation getResourceAnimation(String resourceIdentifierName) throws Resources.NotFoundException {
return AnimationUtils.loadAnimation(getContext(), getResourceIdentifier(resourceIdentifierName, "anim"));
return AnimationUtils.loadAnimation(getContext(), getResourceIdentifierOrThrow(resourceIdentifierName, "anim"));
}
@ColorInt
public static int getResourceColor(String resourceIdentifierName) throws Resources.NotFoundException {
//noinspection deprecation
return getContext().getResources().getColor(getResourceIdentifier(resourceIdentifierName, "color"));
return getContext().getResources().getColor(getResourceIdentifierOrThrow(resourceIdentifierName, "color"));
}
public static int getResourceDimensionPixelSize(String resourceIdentifierName) throws Resources.NotFoundException {
return getContext().getResources().getDimensionPixelSize(getResourceIdentifier(resourceIdentifierName, "dimen"));
return getContext().getResources().getDimensionPixelSize(getResourceIdentifierOrThrow(resourceIdentifierName, "dimen"));
}
public static float getResourceDimension(String resourceIdentifierName) throws Resources.NotFoundException {
return getContext().getResources().getDimension(getResourceIdentifier(resourceIdentifierName, "dimen"));
return getContext().getResources().getDimension(getResourceIdentifierOrThrow(resourceIdentifierName, "dimen"));
}
public static String[] getResourceStringArray(String resourceIdentifierName) throws Resources.NotFoundException {
return getContext().getResources().getStringArray(getResourceIdentifier(resourceIdentifierName, "array"));
return getContext().getResources().getStringArray(getResourceIdentifierOrThrow(resourceIdentifierName, "array"));
}
public interface MatchFilter<T> {
@@ -323,13 +338,9 @@ public class Utils {
* Includes sub children.
*/
public static <R extends View> R getChildViewByResourceName(View view, String str) {
var child = view.findViewById(Utils.getResourceIdentifier(str, "id"));
if (child != null) {
//noinspection unchecked
return (R) child;
}
throw new IllegalArgumentException("View with resource name not found: " + str);
var child = view.findViewById(Utils.getResourceIdentifierOrThrow(str, "id"));
//noinspection unchecked
return (R) child;
}
/**
@@ -415,9 +426,9 @@ public class Utils {
}
public static void setClipboard(CharSequence text) {
android.content.ClipboardManager clipboard = (android.content.ClipboardManager) context
ClipboardManager clipboard = (ClipboardManager) context
.getSystemService(Context.CLIPBOARD_SERVICE);
android.content.ClipData clip = android.content.ClipData.newPlainText("ReVanced", text);
ClipData clip = ClipData.newPlainText("ReVanced", text);
clipboard.setPrimaryClip(clip);
}
@@ -577,7 +588,13 @@ public class Utils {
showToast(messageToToast, Toast.LENGTH_LONG);
}
private static void showToast(String messageToToast, int toastDuration) {
/**
* Safe to call from any thread.
*
* @param messageToToast Message to show.
* @param toastDuration Either {@link Toast#LENGTH_SHORT} or {@link Toast#LENGTH_LONG}.
*/
public static void showToast(String messageToToast, int toastDuration) {
Objects.requireNonNull(messageToToast);
runOnMainThreadNowOrLater(() -> {
Context currentContext = context;
@@ -747,396 +764,32 @@ public class Utils {
}
/**
* Creates a custom dialog with a styled layout, including a title, message, buttons, and an
* optional EditText. The dialog's appearance adapts to the app's dark mode setting, with
* rounded corners and customizable button actions. Buttons adjust dynamically to their text
* content and are arranged in a single row if they fit within 80% of the screen width,
* with the Neutral button aligned to the left and OK/Cancel buttons centered on the right.
* If buttons do not fit, each is placed on a separate row, all aligned to the right.
* Configures the parameters of a dialog window, including its width, gravity, vertical offset and background dimming.
* The width is calculated as a percentage of the screen's portrait width and the vertical offset is specified in DIP.
* The default dialog background is removed to allow for custom styling.
*
* @param context Context used to create the dialog.
* @param title Title text of the dialog.
* @param message Message text of the dialog (supports Spanned for HTML), or null if replaced by EditText.
* @param editText EditText to include in the dialog, or null if no EditText is needed.
* @param okButtonText OK button text, or null to use the default "OK" string.
* @param onOkClick Action to perform when the OK button is clicked.
* @param onCancelClick Action to perform when the Cancel button is clicked, or null if no Cancel button is needed.
* @param neutralButtonText Neutral button text, or null if no Neutral button is needed.
* @param onNeutralClick Action to perform when the Neutral button is clicked, or null if no Neutral button is needed.
* @param dismissDialogOnNeutralClick If the dialog should be dismissed when the Neutral button is clicked.
* @return The Dialog and its main LinearLayout container.
* @param window The {@link Window} object to configure.
* @param gravity The gravity for positioning the dialog (e.g., {@link Gravity#BOTTOM}).
* @param yOffsetDip The vertical offset from the gravity position in DIP.
* @param widthPercentage The width of the dialog as a percentage of the screen's portrait width (0-100).
* @param dimAmount If true, sets the background dim amount to 0 (no dimming); if false, leaves the default dim amount.
*/
@SuppressWarnings("ExtractMethodRecommender")
public static Pair<Dialog, LinearLayout> createCustomDialog(
Context context, String title, CharSequence message, @Nullable EditText editText,
String okButtonText, Runnable onOkClick, Runnable onCancelClick,
@Nullable String neutralButtonText, @Nullable Runnable onNeutralClick,
boolean dismissDialogOnNeutralClick
) {
Logger.printDebug(() -> "Creating custom dialog with title: " + title);
Dialog dialog = new Dialog(context);
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); // Remove default title bar.
// Preset size constants.
final int dip4 = dipToPixels(4);
final int dip8 = dipToPixels(8);
final int dip16 = dipToPixels(16);
final int dip24 = dipToPixels(24);
// Create main layout.
LinearLayout mainLayout = new LinearLayout(context);
mainLayout.setOrientation(LinearLayout.VERTICAL);
mainLayout.setPadding(dip24, dip16, dip24, dip24);
// Set rounded rectangle background.
ShapeDrawable mainBackground = new ShapeDrawable(new RoundRectShape(
createCornerRadii(28), null, null));
mainBackground.getPaint().setColor(getDialogBackgroundColor()); // Dialog background.
mainLayout.setBackground(mainBackground);
// Title.
if (!TextUtils.isEmpty(title)) {
TextView titleView = new TextView(context);
titleView.setText(title);
titleView.setTypeface(Typeface.DEFAULT_BOLD);
titleView.setTextSize(18);
titleView.setTextColor(getAppForegroundColor());
titleView.setGravity(Gravity.CENTER);
LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
layoutParams.setMargins(0, 0, 0, dip16);
titleView.setLayoutParams(layoutParams);
mainLayout.addView(titleView);
}
// Create content container (message/EditText) inside a ScrollView only if message or editText is provided.
ScrollView contentScrollView = null;
LinearLayout contentContainer;
if (message != null || editText != null) {
contentScrollView = new ScrollView(context);
contentScrollView.setVerticalScrollBarEnabled(false); // Disable the vertical scrollbar.
contentScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER);
if (editText != null) {
ShapeDrawable scrollViewBackground = new ShapeDrawable(new RoundRectShape(
createCornerRadii(10), null, null));
scrollViewBackground.getPaint().setColor(getEditTextBackground());
contentScrollView.setPadding(dip8, dip8, dip8, dip8);
contentScrollView.setBackground(scrollViewBackground);
contentScrollView.setClipToOutline(true);
}
LinearLayout.LayoutParams contentParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1.0f // Weight to take available space.
);
contentScrollView.setLayoutParams(contentParams);
contentContainer = new LinearLayout(context);
contentContainer.setOrientation(LinearLayout.VERTICAL);
contentScrollView.addView(contentContainer);
// Message (if not replaced by EditText).
if (editText == null) {
TextView messageView = new TextView(context);
messageView.setText(message); // Supports Spanned (HTML).
messageView.setTextSize(16);
messageView.setTextColor(getAppForegroundColor());
// Enable HTML link clicking if the message contains links.
if (message instanceof Spanned) {
messageView.setMovementMethod(LinkMovementMethod.getInstance());
}
LinearLayout.LayoutParams messageParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
messageView.setLayoutParams(messageParams);
contentContainer.addView(messageView);
}
// EditText (if provided).
if (editText != null) {
// Remove EditText from its current parent, if any.
ViewGroup parent = (ViewGroup) editText.getParent();
if (parent != null) {
parent.removeView(editText);
}
// Style the EditText to match the dialog theme.
editText.setTextColor(getAppForegroundColor());
editText.setBackgroundColor(Color.TRANSPARENT);
editText.setPadding(0, 0, 0, 0);
LinearLayout.LayoutParams editTextParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
contentContainer.addView(editText, editTextParams);
}
}
// Button container.
LinearLayout buttonContainer = new LinearLayout(context);
buttonContainer.setOrientation(LinearLayout.VERTICAL);
buttonContainer.removeAllViews();
LinearLayout.LayoutParams buttonContainerParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
buttonContainerParams.setMargins(0, dip16, 0, 0);
buttonContainer.setLayoutParams(buttonContainerParams);
// Lists to track buttons.
List<Button> buttons = new ArrayList<>();
List<Integer> buttonWidths = new ArrayList<>();
// Create buttons in order: Neutral, Cancel, OK.
if (neutralButtonText != null && onNeutralClick != null) {
Button neutralButton = addButton(
context,
neutralButtonText,
onNeutralClick,
false,
dismissDialogOnNeutralClick,
dialog
);
buttons.add(neutralButton);
neutralButton.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED);
buttonWidths.add(neutralButton.getMeasuredWidth());
}
if (onCancelClick != null) {
Button cancelButton = addButton(
context,
context.getString(android.R.string.cancel),
onCancelClick,
false,
true,
dialog
);
buttons.add(cancelButton);
cancelButton.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED);
buttonWidths.add(cancelButton.getMeasuredWidth());
}
if (onOkClick != null) {
Button okButton = addButton(
context,
okButtonText != null ? okButtonText : context.getString(android.R.string.ok),
onOkClick,
true,
true,
dialog
);
buttons.add(okButton);
okButton.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED);
buttonWidths.add(okButton.getMeasuredWidth());
}
// Handle button layout.
int screenWidth = context.getResources().getDisplayMetrics().widthPixels;
int totalWidth = 0;
for (Integer width : buttonWidths) {
totalWidth += width;
}
if (buttonWidths.size() > 1) {
totalWidth += (buttonWidths.size() - 1) * dip8; // Add margins for gaps.
}
if (buttons.size() == 1) {
// Single button: stretch to full width.
Button singleButton = buttons.get(0);
LinearLayout singleContainer = new LinearLayout(context);
singleContainer.setOrientation(LinearLayout.HORIZONTAL);
singleContainer.setGravity(Gravity.CENTER);
ViewGroup parent = (ViewGroup) singleButton.getParent();
if (parent != null) {
parent.removeView(singleButton);
}
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dipToPixels(36)
);
params.setMargins(0, 0, 0, 0);
singleButton.setLayoutParams(params);
singleContainer.addView(singleButton);
buttonContainer.addView(singleContainer);
} else if (buttons.size() > 1) {
// Check if buttons fit in one row.
if (totalWidth <= screenWidth * 0.8) {
// Single row: Neutral, Cancel, OK.
LinearLayout rowContainer = new LinearLayout(context);
rowContainer.setOrientation(LinearLayout.HORIZONTAL);
rowContainer.setGravity(Gravity.CENTER);
rowContainer.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
// Add all buttons with proportional weights and specific margins.
for (int i = 0; i < buttons.size(); i++) {
Button button = buttons.get(i);
ViewGroup parent = (ViewGroup) button.getParent();
if (parent != null) {
parent.removeView(button);
}
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
0,
dipToPixels(36),
buttonWidths.get(i) // Use measured width as weight.
);
// Set margins based on button type and combination.
if (buttons.size() == 2) {
// Neutral + OK or Cancel + OK.
if (i == 0) { // Neutral or Cancel.
params.setMargins(0, 0, dip4, 0);
} else { // OK
params.setMargins(dip4, 0, 0, 0);
}
} else if (buttons.size() == 3) {
if (i == 0) { // Neutral.
params.setMargins(0, 0, dip4, 0);
} else if (i == 1) { // Cancel
params.setMargins(dip4, 0, dip4, 0);
} else { // OK
params.setMargins(dip4, 0, 0, 0);
}
}
button.setLayoutParams(params);
rowContainer.addView(button);
}
buttonContainer.addView(rowContainer);
} else {
// Multiple rows: OK, Cancel, Neutral.
List<Button> reorderedButtons = new ArrayList<>();
// Reorder: OK, Cancel, Neutral.
if (onOkClick != null) {
reorderedButtons.add(buttons.get(buttons.size() - 1));
}
if (onCancelClick != null) {
reorderedButtons.add(buttons.get((neutralButtonText != null && onNeutralClick != null) ? 1 : 0));
}
if (neutralButtonText != null && onNeutralClick != null) {
reorderedButtons.add(buttons.get(0));
}
// Add each button in its own row with spacers.
for (int i = 0; i < reorderedButtons.size(); i++) {
Button button = reorderedButtons.get(i);
LinearLayout singleContainer = new LinearLayout(context);
singleContainer.setOrientation(LinearLayout.HORIZONTAL);
singleContainer.setGravity(Gravity.CENTER);
singleContainer.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dipToPixels(36)
));
ViewGroup parent = (ViewGroup) button.getParent();
if (parent != null) {
parent.removeView(button);
}
LinearLayout.LayoutParams buttonParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dipToPixels(36)
);
buttonParams.setMargins(0, 0, 0, 0);
button.setLayoutParams(buttonParams);
singleContainer.addView(button);
buttonContainer.addView(singleContainer);
// Add a spacer between the buttons (except the last one).
// Adding a margin between buttons is not suitable, as it conflicts with the single row layout.
if (i < reorderedButtons.size() - 1) {
View spacer = new View(context);
LinearLayout.LayoutParams spacerParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dipToPixels(8)
);
spacer.setLayoutParams(spacerParams);
buttonContainer.addView(spacer);
}
}
}
}
// Add ScrollView to main layout only if content exist.
if (contentScrollView != null) {
mainLayout.addView(contentScrollView);
}
mainLayout.addView(buttonContainer);
dialog.setContentView(mainLayout);
// Set dialog window attributes.
Window window = dialog.getWindow();
if (window != null) {
setDialogWindowParameters(window);
}
return new Pair<>(dialog, mainLayout);
}
public static void setDialogWindowParameters(Window window) {
public static void setDialogWindowParameters(Window window, int gravity, int yOffsetDip, int widthPercentage, boolean dimAmount) {
WindowManager.LayoutParams params = window.getAttributes();
DisplayMetrics displayMetrics = Resources.getSystem().getDisplayMetrics();
int portraitWidth = (int) (displayMetrics.widthPixels * 0.9);
int portraitWidth = Math.min(displayMetrics.widthPixels, displayMetrics.heightPixels);
if (Resources.getSystem().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
portraitWidth = (int) Math.min(portraitWidth, displayMetrics.heightPixels * 0.9);
}
params.width = portraitWidth;
params.width = (int) (portraitWidth * (widthPercentage / 100.0f)); // Set width based on parameters.
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.gravity = Gravity.CENTER;
window.setAttributes(params);
window.setBackgroundDrawable(null); // Remove default dialog background.
}
params.gravity = gravity;
params.y = yOffsetDip > 0 ? dipToPixels(yOffsetDip) : 0;
if (dimAmount) {
params.dimAmount = 0f;
}
/**
* Adds a styled button to a dialog's button container with customizable text, click behavior, and appearance.
* The button's background and text colors adapt to the app's dark mode setting. Buttons stretch to full width
* when on separate rows or proportionally based on content when in a single row (Neutral, Cancel, OK order).
* When wrapped to separate rows, buttons are ordered OK, Cancel, Neutral.
*
* @param context Context to create the button and access resources.
* @param buttonText Button text to display.
* @param onClick Action to perform when the button is clicked, or null if no action is required.
* @param isOkButton If this is the OK button, which uses distinct background and text colors.
* @param dismissDialog If the dialog should be dismissed when the button is clicked.
* @param dialog The Dialog to dismiss when the button is clicked.
* @return The created Button.
*/
private static Button addButton(Context context, String buttonText, Runnable onClick,
boolean isOkButton, boolean dismissDialog, Dialog dialog) {
Button button = new Button(context, null, 0);
button.setText(buttonText);
button.setTextSize(14);
button.setAllCaps(false);
button.setSingleLine(true);
button.setEllipsize(android.text.TextUtils.TruncateAt.END);
button.setGravity(Gravity.CENTER);
ShapeDrawable background = new ShapeDrawable(new RoundRectShape(createCornerRadii(20), null, null));
int backgroundColor = isOkButton
? getOkButtonBackgroundColor() // Background color for OK button (inversion).
: getCancelOrNeutralButtonBackgroundColor(); // Background color for Cancel or Neutral buttons.
background.getPaint().setColor(backgroundColor);
button.setBackground(background);
button.setTextColor(isDarkModeEnabled()
? (isOkButton ? Color.BLACK : Color.WHITE)
: (isOkButton ? Color.WHITE : Color.BLACK));
// Set internal padding.
final int dip16 = dipToPixels(16);
button.setPadding(dip16, 0, dip16, 0);
button.setOnClickListener(v -> {
if (onClick != null) {
onClick.run();
}
if (dismissDialog) {
dialog.dismiss();
}
});
return button;
window.setAttributes(params); // Apply window attributes.
window.setBackgroundDrawable(null); // Remove default dialog background
}
/**
@@ -1323,9 +976,9 @@ public class Utils {
/**
* Sort a PreferenceGroup and all it's sub groups by title or key.
*
* <p>
* Sort order is determined by the preferences key {@link Sort} suffix.
*
* <p>
* If a preference has no key or no {@link Sort} suffix,
* then the preferences are left unsorted.
*/
@@ -1388,7 +1041,7 @@ public class Utils {
* Set all preferences to multiline titles if the device is not using an English variant.
* The English strings are heavily scrutinized and all titles fit on screen
* except 2 or 3 preference strings and those do not affect readability.
*
* <p>
* Allowing multiline for those 2 or 3 English preferences looks weird and out of place,
* and visually it looks better to clip the text and keep all titles 1 line.
*/
@@ -1497,9 +1150,9 @@ public class Utils {
blue = Math.round(blue + (255 - blue) * t);
} else {
// Darken or no change: Scale toward black.
red *= factor;
green *= factor;
blue *= factor;
red = Math.round(red * factor);
green = Math.round(green * factor);
blue = Math.round(blue * factor);
}
// Ensure values are within [0, 255].

View File

@@ -3,7 +3,6 @@ package app.revanced.extension.shared.checks;
import static android.text.Html.FROM_HTML_MODE_COMPACT;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.shared.Utils.DialogFragmentOnStartAction;
import static app.revanced.extension.shared.Utils.dipToPixels;
import android.annotation.SuppressLint;
import android.app.Activity;
@@ -26,6 +25,7 @@ import java.util.Collection;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.ui.CustomDialog;
abstract class Check {
private static final int NUMBER_OF_TIMES_TO_IGNORE_WARNING_BEFORE_DISABLING = 2;
@@ -93,7 +93,7 @@ abstract class Check {
Utils.runOnMainThreadDelayed(() -> {
// Create the custom dialog.
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
activity,
str("revanced_check_environment_failed_title"), // Title.
message, // Message.
@@ -127,7 +127,8 @@ abstract class Check {
// Add icon to the dialog.
ImageView iconView = new ImageView(activity);
iconView.setImageResource(Utils.getResourceIdentifier("revanced_ic_dialog_alert", "drawable"));
iconView.setImageResource(Utils.getResourceIdentifierOrThrow(
"revanced_ic_dialog_alert", "drawable"));
iconView.setColorFilter(Utils.getAppForegroundColor(), PorterDuff.Mode.SRC_IN);
iconView.setPadding(0, 0, 0, 0);
LinearLayout.LayoutParams iconParams = new LinearLayout.LayoutParams(
@@ -158,8 +159,8 @@ abstract class Check {
Button ignoreButton;
// Check if buttons are in a single-row layout (buttonContainer has one child: rowContainer).
if (buttonContainer.getChildCount() == 1 && buttonContainer.getChildAt(0) instanceof LinearLayout) {
LinearLayout rowContainer = (LinearLayout) buttonContainer.getChildAt(0);
if (buttonContainer.getChildCount() == 1
&& buttonContainer.getChildAt(0) instanceof LinearLayout rowContainer) {
// Neutral button is the first child (index 0).
ignoreButton = (Button) rowContainer.getChildAt(0);
// OK button is the last child.

View File

@@ -0,0 +1,71 @@
package app.revanced.extension.shared.fixes.redgifs;
import androidx.annotation.NonNull;
import org.json.JSONException;
import java.io.IOException;
import java.net.HttpURLConnection;
import app.revanced.extension.shared.Logger;
import okhttp3.Interceptor;
import okhttp3.MediaType;
import okhttp3.Protocol;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
public abstract class BaseFixRedgifsApiPatch implements Interceptor {
protected static BaseFixRedgifsApiPatch INSTANCE;
public abstract String getDefaultUserAgent();
@NonNull
@Override
public Response intercept(@NonNull Chain chain) throws IOException {
Request request = chain.request();
if (!request.url().host().equals("api.redgifs.com")) {
return chain.proceed(request);
}
String userAgent = getDefaultUserAgent();
if (request.header("Authorization") != null) {
Response response = chain.proceed(request.newBuilder().header("User-Agent", userAgent).build());
if (response.isSuccessful()) {
return response;
}
// It's possible that the user agent is being overwritten later down in the interceptor
// chain, so make sure we grab the new user agent from the request headers.
userAgent = response.request().header("User-Agent");
response.close();
}
try {
RedgifsTokenManager.RedgifsToken token = RedgifsTokenManager.refreshToken(userAgent);
// Emulate response for old OAuth endpoint
if (request.url().encodedPath().equals("/v2/oauth/client")) {
String responseBody = RedgifsTokenManager.getEmulatedOAuthResponseBody(token);
return new Response.Builder()
.message("OK")
.code(HttpURLConnection.HTTP_OK)
.protocol(Protocol.HTTP_1_1)
.request(request)
.header("Content-Type", "application/json")
.body(ResponseBody.create(
responseBody, MediaType.get("application/json")))
.build();
}
Request modifiedRequest = request.newBuilder()
.header("Authorization", "Bearer " + token.getAccessToken())
.header("User-Agent", userAgent)
.build();
return chain.proceed(modifiedRequest);
} catch (JSONException ex) {
Logger.printException(() -> "Could not parse Redgifs response", ex);
throw new IOException(ex);
}
}
}

View File

@@ -0,0 +1,94 @@
package app.revanced.extension.shared.fixes.redgifs;
import static app.revanced.extension.shared.requests.Route.Method.GET;
import androidx.annotation.GuardedBy;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import app.revanced.extension.shared.requests.Requester;
/**
* Manages Redgifs token lifecycle.
*/
public class RedgifsTokenManager {
public static class RedgifsToken {
// Expire after 23 hours to provide some breathing room
private static final long EXPIRY_SECONDS = 23 * 60 * 60;
private final String accessToken;
private final long refreshTimeInSeconds;
public RedgifsToken(String accessToken, long refreshTime) {
this.accessToken = accessToken;
this.refreshTimeInSeconds = refreshTime;
}
public String getAccessToken() {
return accessToken;
}
public long getExpiryTimeInSeconds() {
return refreshTimeInSeconds + EXPIRY_SECONDS;
}
public boolean isValid() {
if (accessToken == null) return false;
return getExpiryTimeInSeconds() >= System.currentTimeMillis() / 1000;
}
}
public static final String REDGIFS_API_HOST = "https://api.redgifs.com";
private static final String GET_TEMPORARY_TOKEN = REDGIFS_API_HOST + "/v2/auth/temporary";
@GuardedBy("itself")
private static final Map<String, RedgifsToken> tokenMap = new HashMap<>();
private static String getToken(String userAgent) throws IOException, JSONException {
HttpURLConnection connection = (HttpURLConnection) new URL(GET_TEMPORARY_TOKEN).openConnection();
connection.setFixedLengthStreamingMode(0);
connection.setRequestMethod(GET.name());
connection.setRequestProperty("User-Agent", userAgent);
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Accept", "application/json");
connection.setUseCaches(false);
JSONObject responseObject = Requester.parseJSONObject(connection);
return responseObject.getString("token");
}
public static RedgifsToken refreshToken(String userAgent) throws IOException, JSONException {
synchronized(tokenMap) {
// Reference: https://github.com/JeffreyCA/Apollo-ImprovedCustomApi/pull/67
RedgifsToken token = tokenMap.get(userAgent);
if (token != null && token.isValid()) {
return token;
}
// Copy user agent from original request if present because Redgifs verifies
// that the user agent in subsequent requests matches the one in the OAuth token.
String accessToken = getToken(userAgent);
long refreshTime = System.currentTimeMillis() / 1000;
token = new RedgifsToken(accessToken, refreshTime);
tokenMap.put(userAgent, token);
return token;
}
}
public static String getEmulatedOAuthResponseBody(RedgifsToken token) throws JSONException {
// Reference: https://github.com/JeffreyCA/Apollo-ImprovedCustomApi/pull/67
JSONObject responseObject = new JSONObject();
responseObject.put("access_token", token.accessToken);
responseObject.put("expiry_time", token.getExpiryTimeInSeconds() - (System.currentTimeMillis() / 1000));
responseObject.put("scope", "read");
responseObject.put("token_type", "Bearer");
return responseObject.toString();
}
}

View File

@@ -1,4 +1,4 @@
package app.revanced.extension.youtube.patches;
package app.revanced.extension.shared.patches;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

View File

@@ -0,0 +1,29 @@
package app.revanced.extension.shared.patches;
import app.revanced.extension.shared.settings.BaseSettings;
/**
* YouTube and YouTube Music.
*/
@SuppressWarnings("unused")
public final class SanitizeSharingLinksPatch {
private static final String NEW_TRACKING_PARAMETER_REGEX = ".si=.+";
private static final String OLD_TRACKING_PARAMETER_REGEX = ".feature=.+";
/**
* Injection point.
*/
public static String sanitize(String url) {
if (BaseSettings.SANITIZE_SHARED_LINKS.get()) {
url = url
.replaceAll(NEW_TRACKING_PARAMETER_REGEX, "")
.replaceAll(OLD_TRACKING_PARAMETER_REGEX, "");
}
if (BaseSettings.REPLACE_MUSIC_LINKS_WITH_YOUTUBE.get()) {
url = url.replace("music.youtube.com", "youtube.com");
}
return url;
}
}

View File

@@ -0,0 +1,167 @@
package app.revanced.extension.shared.settings;
import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.preference.PreferenceFragment;
import android.util.TypedValue;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toolbar;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.preference.ToolbarPreferenceFragment;
/**
* Base class for hooking activities to inject a custom PreferenceFragment with a toolbar.
* Provides common logic for initializing the activity and setting up the toolbar.
*/
@SuppressWarnings({"deprecation", "NewApi"})
public abstract class BaseActivityHook extends Activity {
private static final int ID_REVANCED_SETTINGS_FRAGMENTS =
getResourceIdentifierOrThrow("revanced_settings_fragments", "id");
private static final int ID_REVANCED_TOOLBAR_PARENT =
getResourceIdentifierOrThrow("revanced_toolbar_parent", "id");
public static final int LAYOUT_REVANCED_SETTINGS_WITH_TOOLBAR =
getResourceIdentifierOrThrow("revanced_settings_with_toolbar", "layout");
private static final int STRING_REVANCED_SETTINGS_TITLE =
getResourceIdentifierOrThrow("revanced_settings_title", "string");
/**
* Layout parameters for the toolbar, extracted from the dummy toolbar.
*/
protected static ViewGroup.LayoutParams toolbarLayoutParams;
/**
* Sets the layout parameters for the toolbar.
*/
public static void setToolbarLayoutParams(Toolbar toolbar) {
if (toolbarLayoutParams != null) {
toolbar.setLayoutParams(toolbarLayoutParams);
}
}
/**
* Initializes the activity by setting the theme, content view and injecting a PreferenceFragment.
*/
public static void initialize(BaseActivityHook hook, Activity activity) {
try {
hook.customizeActivityTheme(activity);
activity.setContentView(hook.getContentViewResourceId());
// Sanity check.
String dataString = activity.getIntent().getDataString();
if (!"revanced_settings_intent".equals(dataString)) {
Logger.printException(() -> "Unknown intent: " + dataString);
return;
}
PreferenceFragment fragment = hook.createPreferenceFragment();
hook.createToolbar(activity, fragment);
activity.getFragmentManager()
.beginTransaction()
.replace(ID_REVANCED_SETTINGS_FRAGMENTS, fragment)
.commit();
} catch (Exception ex) {
Logger.printException(() -> "initialize failure", ex);
}
}
/**
* Injection point.
* Overrides the ReVanced settings language.
*/
@SuppressWarnings("unused")
public static Context getAttachBaseContext(Context original) {
AppLanguage language = BaseSettings.REVANCED_LANGUAGE.get();
if (language == AppLanguage.DEFAULT) {
return original;
}
return Utils.getContext();
}
/**
* Creates and configures a toolbar for the activity, replacing a dummy placeholder.
*/
@SuppressLint("UseCompatLoadingForDrawables")
protected void createToolbar(Activity activity, PreferenceFragment fragment) {
// Replace dummy placeholder toolbar.
// This is required to fix submenu title alignment issue with Android ASOP 15+
ViewGroup toolBarParent = activity.findViewById(ID_REVANCED_TOOLBAR_PARENT);
ViewGroup dummyToolbar = Utils.getChildViewByResourceName(toolBarParent, "revanced_toolbar");
toolbarLayoutParams = dummyToolbar.getLayoutParams();
toolBarParent.removeView(dummyToolbar);
// Sets appropriate system navigation bar color for the activity.
ToolbarPreferenceFragment.setNavigationBarColor(activity.getWindow());
Toolbar toolbar = new Toolbar(toolBarParent.getContext());
toolbar.setBackgroundColor(getToolbarBackgroundColor());
toolbar.setNavigationIcon(getNavigationIcon());
toolbar.setNavigationOnClickListener(getNavigationClickListener(activity));
toolbar.setTitle(STRING_REVANCED_SETTINGS_TITLE);
final int margin = Utils.dipToPixels(16);
toolbar.setTitleMarginStart(margin);
toolbar.setTitleMarginEnd(margin);
TextView toolbarTextView = Utils.getChildView(toolbar, false, view -> view instanceof TextView);
if (toolbarTextView != null) {
toolbarTextView.setTextColor(Utils.getAppForegroundColor());
toolbarTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
}
setToolbarLayoutParams(toolbar);
onPostToolbarSetup(activity, toolbar, fragment);
toolBarParent.addView(toolbar, 0);
}
/**
* Customizes the activity's theme.
*/
protected abstract void customizeActivityTheme(Activity activity);
/**
* Returns the resource ID for the content view layout.
*/
protected abstract int getContentViewResourceId();
/**
* Returns the background color for the toolbar.
*/
protected abstract int getToolbarBackgroundColor();
/**
* Returns the navigation icon drawable for the toolbar.
*/
protected abstract Drawable getNavigationIcon();
/**
* Returns the click listener for the toolbar's navigation icon.
*/
protected abstract View.OnClickListener getNavigationClickListener(Activity activity);
/**
* Creates the PreferenceFragment to be injected into the activity.
*/
protected PreferenceFragment createPreferenceFragment() {
return new ToolbarPreferenceFragment();
}
/**
* Performs additional setup after the toolbar is configured.
*
* @param activity The activity hosting the toolbar.
* @param toolbar The configured toolbar.
* @param fragment The PreferenceFragment associated with the activity.
*/
protected void onPostToolbarSetup(Activity activity, Toolbar toolbar, PreferenceFragment fragment) {}
}

View File

@@ -4,9 +4,6 @@ import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
import static app.revanced.extension.shared.settings.Setting.parent;
import static app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch.AudioStreamLanguageOverrideAvailability;
import static app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch.SpoofiOSAvailability;
import app.revanced.extension.shared.spoof.ClientType;
/**
* Settings shared across multiple apps.
@@ -28,12 +25,13 @@ public class BaseSettings {
*/
public static final BooleanSetting SHOW_MENU_ICONS = new BooleanSetting("revanced_show_menu_icons", TRUE, true);
public static final BooleanSetting SETTINGS_SEARCH_HISTORY = new BooleanSetting("revanced_settings_search_history", TRUE, true);
public static final StringSetting SETTINGS_SEARCH_ENTRIES = new StringSetting("revanced_settings_search_entries", "");
public static final BooleanSetting SPOOF_VIDEO_STREAMS = new BooleanSetting("revanced_spoof_video_streams", TRUE, true, "revanced_spoof_video_streams_user_dialog_message");
public static final EnumSetting<AppLanguage> SPOOF_VIDEO_STREAMS_LANGUAGE = new EnumSetting<>("revanced_spoof_video_streams_language", AppLanguage.DEFAULT, new AudioStreamLanguageOverrideAvailability());
public static final BooleanSetting SPOOF_STREAMING_DATA_STATS_FOR_NERDS = new BooleanSetting("revanced_spoof_streaming_data_stats_for_nerds", TRUE, parent(SPOOF_VIDEO_STREAMS));
public static final BooleanSetting SPOOF_VIDEO_STREAMS_IOS_FORCE_AVC = new BooleanSetting("revanced_spoof_video_streams_ios_force_avc", FALSE, true,
"revanced_spoof_video_streams_ios_force_avc_user_dialog_message", new SpoofiOSAvailability());
// Client type must be last spoof setting due to cyclic references.
public static final EnumSetting<ClientType> SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_video_streams_client_type", ClientType.ANDROID_UNPLUGGED, true, parent(SPOOF_VIDEO_STREAMS));
public static final BooleanSetting SANITIZE_SHARED_LINKS = new BooleanSetting("revanced_sanitize_sharing_links", TRUE);
public static final BooleanSetting REPLACE_MUSIC_LINKS_WITH_YOUTUBE = new BooleanSetting("revanced_replace_music_with_youtube", FALSE);
}

View File

@@ -1,18 +1,27 @@
package app.revanced.extension.shared.settings;
import static app.revanced.extension.shared.StringRef.str;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.StringRef;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.preference.SharedPrefCategory;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.*;
import static app.revanced.extension.shared.StringRef.str;
public abstract class Setting<T> {
@@ -23,24 +32,49 @@ public abstract class Setting<T> {
*/
public interface Availability {
boolean isAvailable();
/**
* @return parent settings (dependencies) of this availability.
*/
default List<Setting<?>> getParentSettings() {
return Collections.emptyList();
}
}
/**
* Availability based on a single parent setting being enabled.
*/
public static Availability parent(BooleanSetting parent) {
return parent::get;
return new Availability() {
@Override
public boolean isAvailable() {
return parent.get();
}
@Override
public List<Setting<?>> getParentSettings() {
return Collections.singletonList(parent);
}
};
}
/**
* Availability based on all parents being enabled.
*/
public static Availability parentsAll(BooleanSetting... parents) {
return () -> {
for (BooleanSetting parent : parents) {
if (!parent.get()) return false;
return new Availability() {
@Override
public boolean isAvailable() {
for (BooleanSetting parent : parents) {
if (!parent.get()) return false;
}
return true;
}
@Override
public List<Setting<?>> getParentSettings() {
return Collections.unmodifiableList(Arrays.asList(parents));
}
return true;
};
}
@@ -48,11 +82,19 @@ public abstract class Setting<T> {
* Availability based on any parent being enabled.
*/
public static Availability parentsAny(BooleanSetting... parents) {
return () -> {
for (BooleanSetting parent : parents) {
if (parent.get()) return true;
return new Availability() {
@Override
public boolean isAvailable() {
for (BooleanSetting parent : parents) {
if (parent.get()) return true;
}
return false;
}
@Override
public List<Setting<?>> getParentSettings() {
return Collections.unmodifiableList(Arrays.asList(parents));
}
return false;
};
}
@@ -112,6 +154,7 @@ public abstract class Setting<T> {
* @return All settings that have been created, sorted by keys.
*/
private static List<Setting<?>> allLoadedSettingsSorted() {
//noinspection ComparatorCombinators
Collections.sort(SETTINGS, (Setting<?> o1, Setting<?> o2) -> o1.key.compareTo(o2.key));
return allLoadedSettings();
}
@@ -207,9 +250,7 @@ public abstract class Setting<T> {
SETTINGS.add(this);
if (PATH_TO_SETTINGS.put(key, this) != null) {
// Debug setting may not be created yet so using Logger may cause an initialization crash.
// Show a toast instead.
Utils.showToastLong(this.getClass().getSimpleName()
Logger.printException(() -> this.getClass().getSimpleName()
+ " error: Duplicate Setting key found: " + key);
}
@@ -231,10 +272,10 @@ public abstract class Setting<T> {
/**
* Migrate an old Setting value previously stored in a different SharedPreference.
*
* <p>
* This method will be deleted in the future.
*/
@SuppressWarnings("rawtypes")
@SuppressWarnings({"rawtypes", "NewApi"})
public static void migrateFromOldPreferences(SharedPrefCategory oldPrefs, Setting setting, String settingKey) {
if (!oldPrefs.preferences.contains(settingKey)) {
return; // Nothing to do.
@@ -254,7 +295,7 @@ public abstract class Setting<T> {
migratedValue = oldPrefs.getString(settingKey, (String) newValue);
} else {
Logger.printException(() -> "Unknown setting: " + setting);
// Remove otherwise it'll show a toast on every launch
// Remove otherwise it'll show a toast on every launch.
oldPrefs.preferences.edit().remove(settingKey).apply();
return;
}
@@ -273,7 +314,7 @@ public abstract class Setting<T> {
/**
* Sets, but does _not_ persistently save the value.
* This method is only to be used by the Settings preference code.
*
* <p>
* This intentionally is a static method to deter
* accidental usage when {@link #save(Object)} was intended.
*/
@@ -349,6 +390,14 @@ public abstract class Setting<T> {
return availability == null || availability.isAvailable();
}
/**
* Get the parent Settings that this setting depends on.
* @return List of parent Settings (e.g., BooleanSetting or EnumSetting), or empty list if no dependencies exist.
*/
public List<Setting<?>> getParentSettings() {
return availability == null ? Collections.emptyList() : availability.getParentSettings();
}
/**
* @return if the currently set value is the same as {@link #defaultValue}
*/
@@ -467,9 +516,12 @@ public abstract class Setting<T> {
callback.settingsImported(alertDialogContext);
}
Utils.showToastLong(numberOfSettingsImported == 0
? str("revanced_settings_import_reset")
: str("revanced_settings_import_success", numberOfSettingsImported));
// Use a delay, otherwise the toast can move about on screen from the dismissing dialog.
final int numberOfSettingsImportedFinal = numberOfSettingsImported;
Utils.runOnMainThreadDelayed(() -> Utils.showToastLong(numberOfSettingsImportedFinal == 0
? str("revanced_settings_import_reset")
: str("revanced_settings_import_success", numberOfSettingsImportedFinal)),
150);
return rebootSettingChanged;
} catch (JSONException | IllegalArgumentException ex) {

View File

@@ -27,6 +27,7 @@ import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.settings.BooleanSetting;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.shared.ui.CustomDialog;
@SuppressWarnings("deprecation")
public abstract class AbstractPreferenceFragment extends PreferenceFragment {
@@ -124,7 +125,7 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
showingUserDialogMessage = true;
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context,
confirmDialogTitle, // Title.
Objects.requireNonNull(setting.userDialogMessage).toString(), // No message.
@@ -248,7 +249,8 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
Setting.privateSetValueFromString(setting, listPref.getValue());
}
updateListPreferenceSummary(listPref, setting);
} else {
} else if (!pref.getClass().equals(Preference.class)) {
// Ignore root preference class because there is no data to sync.
Logger.printException(() -> "Setting cannot be handled: " + pref.getClass() + ": " + pref);
}
}
@@ -302,7 +304,8 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
restartDialogButtonText = str("revanced_settings_restart");
}
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(context,
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context,
restartDialogTitle, // Title.
restartDialogMessage, // Message.
null, // No EditText.

View File

@@ -0,0 +1,78 @@
package app.revanced.extension.shared.settings.preference;
import android.content.Context;
import android.preference.Preference;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.SpannedString;
import android.text.TextUtils;
import android.text.style.BulletSpan;
import android.util.AttributeSet;
/**
* Formats the summary text bullet points into Spanned text for better presentation.
*/
@SuppressWarnings({"unused", "deprecation"})
public class BulletPointPreference extends Preference {
public static SpannedString formatIntoBulletPoints(CharSequence source) {
SpannableStringBuilder builder = new SpannableStringBuilder(source);
int lineStart = 0;
int length = builder.length();
while (lineStart < length) {
int lineEnd = TextUtils.indexOf(builder, '\n', lineStart);
if (lineEnd < 0) lineEnd = length;
// Apply BulletSpan only if the line starts with the '•' character.
if (lineEnd > lineStart && builder.charAt(lineStart) == '•') {
int deleteEnd = lineStart + 1; // remove the bullet itself
// If there's a single space right after the bullet, remove that too.
if (deleteEnd < builder.length() && builder.charAt(deleteEnd) == ' ') {
deleteEnd++;
}
builder.delete(lineStart, deleteEnd);
// Apply the BulletSpan to the remainder of that line.
builder.setSpan(new BulletSpan(20),
lineStart,
lineEnd - (deleteEnd - lineStart), // adjust for deleted chars.
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
// Update total length and lineEnd after deletion.
length = builder.length();
final int removed = deleteEnd - lineStart;
lineEnd -= removed;
}
lineStart = lineEnd + 1;
}
return new SpannedString(builder);
}
public BulletPointPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public BulletPointPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public BulletPointPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
public BulletPointPreference(Context context) {
super(context);
}
@Override
public void setSummary(CharSequence summary) {
super.setSummary(formatIntoBulletPoints(summary));
}
}

View File

@@ -0,0 +1,45 @@
package app.revanced.extension.shared.settings.preference;
import static app.revanced.extension.shared.settings.preference.BulletPointPreference.formatIntoBulletPoints;
import android.content.Context;
import android.preference.SwitchPreference;
import android.util.AttributeSet;
/**
* Formats the summary text bullet points into Spanned text for better presentation.
*/
@SuppressWarnings({"unused", "deprecation"})
public class BulletPointSwitchPreference extends SwitchPreference {
public BulletPointSwitchPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public BulletPointSwitchPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public BulletPointSwitchPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
public BulletPointSwitchPreference(Context context) {
super(context);
}
@Override
public void setSummary(CharSequence summary) {
super.setSummary(formatIntoBulletPoints(summary));
}
@Override
public void setSummaryOn(CharSequence summaryOn) {
super.setSummaryOn(formatIntoBulletPoints(summaryOn));
}
@Override
public void setSummaryOff(CharSequence summaryOff) {
super.setSummaryOff(formatIntoBulletPoints(summaryOff));
}
}

View File

@@ -1,9 +1,8 @@
package app.revanced.extension.youtube.settings.preference;
package app.revanced.extension.shared.settings.preference;
import android.content.Context;
import android.util.AttributeSet;
import android.preference.Preference;
import app.revanced.extension.shared.settings.preference.LogBufferManager;
/**
* A custom preference that clears the ReVanced debug log buffer when clicked.

View File

@@ -1,8 +1,8 @@
package app.revanced.extension.shared.settings.preference;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.shared.Utils.getResourceIdentifier;
import static app.revanced.extension.shared.Utils.dipToPixels;
import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
import android.app.Dialog;
import android.content.Context;
@@ -13,20 +13,20 @@ import android.os.Bundle;
import android.preference.EditTextPreference;
import android.text.Editable;
import android.text.InputType;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextWatcher;
import android.text.style.ForegroundColorSpan;
import android.text.style.RelativeSizeSpan;
import android.util.AttributeSet;
import android.util.Pair;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.*;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import androidx.annotation.ColorInt;
import androidx.annotation.Nullable;
import java.util.Locale;
import java.util.regex.Pattern;
@@ -35,6 +35,8 @@ import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.shared.settings.StringSetting;
import app.revanced.extension.shared.ui.ColorDot;
import app.revanced.extension.shared.ui.CustomDialog;
/**
* A custom preference for selecting a color via a hexadecimal code or a color picker dialog.
@@ -43,100 +45,98 @@ import app.revanced.extension.shared.settings.StringSetting;
*/
@SuppressWarnings({"unused", "deprecation"})
public class ColorPickerPreference extends EditTextPreference {
/** Length of a valid color string of format #RRGGBB (without alpha) or #AARRGGBB (with alpha). */
public static final int COLOR_STRING_LENGTH_WITHOUT_ALPHA = 7;
public static final int COLOR_STRING_LENGTH_WITH_ALPHA = 9;
/**
* Character to show the color appearance.
*/
public static final String COLOR_DOT_STRING = "";
/**
* Length of a valid color string of format #RRGGBB.
*/
public static final int COLOR_STRING_LENGTH = 7;
/**
* Matches everything that is not a hex number/letter.
*/
/** Matches everything that is not a hex number/letter. */
private static final Pattern PATTERN_NOT_HEX = Pattern.compile("[^0-9A-Fa-f]");
/**
* Alpha for dimming when the preference is disabled.
*/
private static final float DISABLED_ALPHA = 0.5f; // 50%
/** Alpha for dimming when the preference is disabled. */
public static final float DISABLED_ALPHA = 0.5f; // 50%
/**
* View displaying a colored dot in the widget area.
*/
/** View displaying a colored dot in the widget area. */
private View widgetColorDot;
/**
* Current color in RGB format (without alpha).
*/
/** Dialog View displaying a colored dot for the selected color preview in the dialog. */
private View dialogColorDot;
/** Current color, including alpha channel if opacity slider is enabled. */
@ColorInt
private int currentColor;
/**
* Associated setting for storing the color value.
*/
/** Associated setting for storing the color value. */
private StringSetting colorSetting;
/**
* Dialog TextWatcher for the EditText to monitor color input changes.
*/
/** Dialog TextWatcher for the EditText to monitor color input changes. */
private TextWatcher colorTextWatcher;
/**
* Dialog TextView displaying a colored dot for the selected color preview in the dialog.
*/
private TextView dialogColorPreview;
/** Dialog color picker view. */
protected ColorPickerView dialogColorPickerView;
/**
* Dialog color picker view.
*/
private ColorPickerView dialogColorPickerView;
/** Listener for color changes. */
protected OnColorChangeListener colorChangeListener;
/** Whether the opacity slider is enabled. */
private boolean opacitySliderEnabled = false;
public static final int ID_REVANCED_COLOR_PICKER_VIEW =
getResourceIdentifierOrThrow("revanced_color_picker_view", "id");
public static final int ID_PREFERENCE_COLOR_DOT =
getResourceIdentifierOrThrow("preference_color_dot", "id");
public static final int LAYOUT_REVANCED_COLOR_DOT_WIDGET =
getResourceIdentifierOrThrow("revanced_color_dot_widget", "layout");
public static final int LAYOUT_REVANCED_COLOR_PICKER =
getResourceIdentifierOrThrow("revanced_color_picker", "layout");
/**
* Removes non valid hex characters, converts to all uppercase,
* and adds # character to the start if not present.
*/
public static String cleanupColorCodeString(String colorString) {
// Remove non-hex chars, convert to uppercase, and ensure correct length
public static String cleanupColorCodeString(String colorString, boolean includeAlpha) {
String result = "#" + PATTERN_NOT_HEX.matcher(colorString)
.replaceAll("").toUpperCase(Locale.ROOT);
if (result.length() < COLOR_STRING_LENGTH) {
int maxLength = includeAlpha ? COLOR_STRING_LENGTH_WITH_ALPHA : COLOR_STRING_LENGTH_WITHOUT_ALPHA;
if (result.length() < maxLength) {
return result;
}
return result.substring(0, COLOR_STRING_LENGTH);
return result.substring(0, maxLength);
}
/**
* @param color RGB color, without an alpha channel.
* @return #RRGGBB hex color string
* @param color Color, with or without alpha channel.
* @param includeAlpha Whether to include the alpha channel in the output string.
* @return #RRGGBB or #AARRGGBB hex color string
*/
public static String getColorString(@ColorInt int color) {
String colorString = String.format("#%06X", color);
if ((color & 0xFF000000) != 0) {
// Likely a bug somewhere.
Logger.printException(() -> "getColorString: color has alpha channel: " + colorString);
public static String getColorString(@ColorInt int color, boolean includeAlpha) {
if (includeAlpha) {
return String.format("#%08X", color);
}
return colorString;
color = color & 0x00FFFFFF; // Mask to strip alpha.
return String.format("#%06X", color);
}
/**
* Creates a Spanned object for a colored dot using SpannableString.
*
* @param color The RGB color (without alpha).
* @return A Spanned object with the colored dot.
* Interface for notifying color changes.
*/
public static Spanned getColorDot(@ColorInt int color) {
SpannableString spannable = new SpannableString(COLOR_DOT_STRING);
spannable.setSpan(new ForegroundColorSpan(color | 0xFF000000), 0, COLOR_DOT_STRING.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
spannable.setSpan(new RelativeSizeSpan(1.5f), 0, 1,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
return spannable;
public interface OnColorChangeListener {
void onColorChanged(String key, int newColor);
}
/**
* Sets the listener for color changes.
*/
public void setOnColorChangeListener(OnColorChangeListener listener) {
this.colorChangeListener = listener;
}
/**
* Enables or disables the opacity slider in the color picker dialog.
*/
public void setOpacitySliderEnabled(boolean enabled) {
this.opacitySliderEnabled = enabled;
}
public ColorPickerPreference(Context context) {
@@ -158,9 +158,13 @@ public class ColorPickerPreference extends EditTextPreference {
* Initializes the preference by setting up the EditText, loading the color, and set the widget layout.
*/
private void init() {
colorSetting = (StringSetting) Setting.getSettingFromPath(getKey());
if (colorSetting == null) {
Logger.printException(() -> "Could not find color setting for: " + getKey());
if (getKey() != null) {
colorSetting = (StringSetting) Setting.getSettingFromPath(getKey());
if (colorSetting == null) {
Logger.printException(() -> "Could not find color setting for: " + getKey());
}
} else {
Logger.printDebug(() -> "initialized without key, settings will be loaded later");
}
EditText editText = getEditText();
@@ -171,27 +175,29 @@ public class ColorPickerPreference extends EditTextPreference {
}
// Set the widget layout to a custom layout containing the colored dot.
setWidgetLayoutResource(getResourceIdentifier("revanced_color_dot_widget", "layout"));
setWidgetLayoutResource(LAYOUT_REVANCED_COLOR_DOT_WIDGET);
}
/**
* Sets the selected color and updates the UI and settings.
*
* @param colorString The color in hexadecimal format (e.g., "#RRGGBB").
* @throws IllegalArgumentException If the color string is invalid.
*/
@Override
public final void setText(String colorString) {
public void setText(String colorString) {
try {
Logger.printDebug(() -> "setText: " + colorString);
super.setText(colorString);
currentColor = Color.parseColor(colorString) & 0x00FFFFFF;
currentColor = Color.parseColor(colorString);
if (colorSetting != null) {
colorSetting.save(getColorString(currentColor));
colorSetting.save(getColorString(currentColor, opacitySliderEnabled));
}
updateColorPreview();
updateDialogColorDot();
updateWidgetColorDot();
// Notify the listener about the color change.
if (colorChangeListener != null) {
colorChangeListener.onColorChanged(getKey(), currentColor);
}
} catch (IllegalArgumentException ex) {
// This code is reached if the user pastes settings json with an invalid color
// since this preference is updated with the new setting text.
@@ -203,38 +209,8 @@ public class ColorPickerPreference extends EditTextPreference {
}
}
@Override
protected void onBindView(View view) {
super.onBindView(view);
widgetColorDot = view.findViewById(getResourceIdentifier(
"revanced_color_dot_widget", "id"));
widgetColorDot.setBackgroundResource(getResourceIdentifier(
"revanced_settings_circle_background", "drawable"));
widgetColorDot.getBackground().setTint(currentColor | 0xFF000000);
widgetColorDot.setAlpha(isEnabled() ? 1.0f : DISABLED_ALPHA);
}
/**
* Updates the color preview TextView with a colored dot.
*/
private void updateColorPreview() {
if (dialogColorPreview != null) {
dialogColorPreview.setText(getColorDot(currentColor));
}
}
private void updateWidgetColorDot() {
if (widgetColorDot != null) {
widgetColorDot.getBackground().setTint(currentColor | 0xFF000000);
widgetColorDot.setAlpha(isEnabled() ? 1.0f : DISABLED_ALPHA);
}
}
/**
* Creates a TextWatcher to monitor changes in the EditText for color input.
*
* @return A TextWatcher that updates the color preview on valid input.
*/
private TextWatcher createColorTextWatcher(ColorPickerView colorPickerView) {
return new TextWatcher() {
@@ -250,15 +226,16 @@ public class ColorPickerPreference extends EditTextPreference {
public void afterTextChanged(Editable edit) {
try {
String colorString = edit.toString();
String sanitizedColorString = cleanupColorCodeString(colorString);
String sanitizedColorString = cleanupColorCodeString(colorString, opacitySliderEnabled);
if (!sanitizedColorString.equals(colorString)) {
edit.replace(0, colorString.length(), sanitizedColorString);
return;
}
if (sanitizedColorString.length() != COLOR_STRING_LENGTH) {
// User is still typing out the color.
int expectedLength = opacitySliderEnabled
? COLOR_STRING_LENGTH_WITH_ALPHA
: COLOR_STRING_LENGTH_WITHOUT_ALPHA;
if (sanitizedColorString.length() != expectedLength) {
return;
}
@@ -266,7 +243,7 @@ public class ColorPickerPreference extends EditTextPreference {
if (currentColor != newColor) {
Logger.printDebug(() -> "afterTextChanged: " + sanitizedColorString);
currentColor = newColor;
updateColorPreview();
updateDialogColorDot();
updateWidgetColorDot();
colorPickerView.setColor(newColor);
}
@@ -279,32 +256,68 @@ public class ColorPickerPreference extends EditTextPreference {
}
/**
* Creates a Dialog with a color preview and EditText for hex color input.
* Hook for subclasses to add a custom view to the top of the dialog.
*/
@Nullable
protected View createExtraDialogContentView(Context context) {
return null; // Default implementation returns no extra view.
}
/**
* Hook for subclasses to handle the OK button click.
*/
protected void onDialogOkClicked() {
// Default implementation does nothing.
}
/**
* Hook for subclasses to handle the Neutral button click.
*/
protected void onDialogNeutralClicked() {
// Default implementation.
try {
final int defaultColor = Color.parseColor(colorSetting.defaultValue);
dialogColorPickerView.setColor(defaultColor);
} catch (Exception ex) {
Logger.printException(() -> "Reset button failure", ex);
}
}
@Override
protected void showDialog(Bundle state) {
Context context = getContext();
// Create content container for all dialog views.
LinearLayout contentContainer = new LinearLayout(context);
contentContainer.setOrientation(LinearLayout.VERTICAL);
// Add extra view from subclass if it exists.
View extraView = createExtraDialogContentView(context);
if (extraView != null) {
contentContainer.addView(extraView);
}
// Inflate color picker view.
View colorPicker = LayoutInflater.from(context).inflate(
getResourceIdentifier("revanced_color_picker", "layout"), null);
dialogColorPickerView = colorPicker.findViewById(
getResourceIdentifier("revanced_color_picker_view", "id"));
View colorPicker = LayoutInflater.from(context).inflate(LAYOUT_REVANCED_COLOR_PICKER, null);
dialogColorPickerView = colorPicker.findViewById(ID_REVANCED_COLOR_PICKER_VIEW);
dialogColorPickerView.setOpacitySliderEnabled(opacitySliderEnabled);
dialogColorPickerView.setColor(currentColor);
contentContainer.addView(colorPicker);
// Horizontal layout for preview and EditText.
LinearLayout inputLayout = new LinearLayout(context);
inputLayout.setOrientation(LinearLayout.HORIZONTAL);
inputLayout.setGravity(Gravity.CENTER_VERTICAL);
dialogColorPreview = new TextView(context);
dialogColorDot = new View(context);
LinearLayout.LayoutParams previewParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
dipToPixels(20),
dipToPixels(20)
);
previewParams.setMargins(dipToPixels(15), 0, dipToPixels(10), 0); // text dot has its own indents so 15, instead 16.
dialogColorPreview.setLayoutParams(previewParams);
inputLayout.addView(dialogColorPreview);
updateColorPreview();
previewParams.setMargins(dipToPixels(16), 0, dipToPixels(10), 0);
dialogColorDot.setLayoutParams(previewParams);
inputLayout.addView(dialogColorDot);
updateDialogColorDot();
EditText editText = getEditText();
ViewParent parent = editText.getParent();
@@ -315,7 +328,7 @@ public class ColorPickerPreference extends EditTextPreference {
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
String currentColorString = getColorString(currentColor);
String currentColorString = getColorString(currentColor, opacitySliderEnabled);
editText.setText(currentColorString);
editText.setSelection(currentColorString.length());
editText.setTypeface(Typeface.MONOSPACE);
@@ -334,16 +347,12 @@ public class ColorPickerPreference extends EditTextPreference {
paddingView.setLayoutParams(params);
inputLayout.addView(paddingView);
// Create content container for color picker and input layout.
LinearLayout contentContainer = new LinearLayout(context);
contentContainer.setOrientation(LinearLayout.VERTICAL);
contentContainer.addView(colorPicker);
contentContainer.addView(inputLayout);
// Create ScrollView to wrap the content container.
ScrollView contentScrollView = new ScrollView(context);
contentScrollView.setVerticalScrollBarEnabled(false); // Disable vertical scrollbar.
contentScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER); // Disable overscroll effect.
contentScrollView.setVerticalScrollBarEnabled(false);
contentScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER);
LinearLayout.LayoutParams scrollViewParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0,
@@ -352,51 +361,43 @@ public class ColorPickerPreference extends EditTextPreference {
contentScrollView.setLayoutParams(scrollViewParams);
contentScrollView.addView(contentContainer);
// Create custom dialog.
final int originalColor = currentColor & 0x00FFFFFF;
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
final int originalColor = currentColor;
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context,
getTitle() != null ? getTitle().toString() : str("revanced_settings_color_picker_title"), // Title.
null, // No message.
null, // No EditText.
null, // OK button text.
() -> {
// OK button action.
getTitle() != null ? getTitle().toString() : str("revanced_settings_color_picker_title"),
null,
null,
null,
() -> { // OK button action.
try {
String colorString = editText.getText().toString();
if (colorString.length() != COLOR_STRING_LENGTH) {
int expectedLength = opacitySliderEnabled
? COLOR_STRING_LENGTH_WITH_ALPHA
: COLOR_STRING_LENGTH_WITHOUT_ALPHA;
if (colorString.length() != expectedLength) {
Utils.showToastShort(str("revanced_settings_color_invalid"));
setText(getColorString(originalColor));
setText(getColorString(originalColor, opacitySliderEnabled));
return;
}
setText(colorString);
onDialogOkClicked();
} catch (Exception ex) {
// Should never happen due to a bad color string,
// since the text is validated and fixed while the user types.
Logger.printException(() -> "OK button failure", ex);
}
},
() -> {
// Cancel button action.
() -> { // Cancel button action.
try {
// Restore the original color.
setText(getColorString(originalColor));
setText(getColorString(originalColor, opacitySliderEnabled));
} catch (Exception ex) {
Logger.printException(() -> "Cancel button failure", ex);
}
},
str("revanced_settings_reset_color"), // Neutral button text.
() -> {
// Neutral button action.
try {
final int defaultColor = Color.parseColor(colorSetting.defaultValue) & 0x00FFFFFF;
// Setting view color causes listener callback into this class.
dialogColorPickerView.setColor(defaultColor);
} catch (Exception ex) {
Logger.printException(() -> "Reset button failure", ex);
}
},
false // Do not dismiss dialog when onNeutralClick.
this::onDialogNeutralClicked, // Neutral button action.
false // Do not dismiss dialog.
);
// Add the ScrollView to the dialog's main layout.
@@ -412,13 +413,13 @@ public class ColorPickerPreference extends EditTextPreference {
return;
}
String updatedColorString = getColorString(color);
String updatedColorString = getColorString(color, opacitySliderEnabled);
Logger.printDebug(() -> "onColorChanged: " + updatedColorString);
currentColor = color;
editText.setText(updatedColorString);
editText.setSelection(updatedColorString.length());
updateColorPreview();
updateDialogColorDot();
updateWidgetColorDot();
});
@@ -437,7 +438,7 @@ public class ColorPickerPreference extends EditTextPreference {
colorTextWatcher = null;
}
dialogColorPreview = null;
dialogColorDot = null;
dialogColorPickerView = null;
}
@@ -446,4 +447,32 @@ public class ColorPickerPreference extends EditTextPreference {
super.setEnabled(enabled);
updateWidgetColorDot();
}
@Override
protected void onBindView(View view) {
super.onBindView(view);
widgetColorDot = view.findViewById(ID_PREFERENCE_COLOR_DOT);
updateWidgetColorDot();
}
private void updateWidgetColorDot() {
if (widgetColorDot == null) return;
ColorDot.applyColorDot(
widgetColorDot,
currentColor,
widgetColorDot.isEnabled()
);
}
private void updateDialogColorDot() {
if (dialogColorDot == null) return;
ColorDot.applyColorDot(
dialogColorDot,
currentColor,
true
);
}
}

View File

@@ -23,57 +23,73 @@ import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
/**
* A custom color picker view that allows the user to select a color using a hue slider and a saturation-value selector.
* A custom color picker view that allows the user to select a color using a hue slider, a saturation-value selector
* and an optional opacity slider.
* This implementation is density-independent and responsive across different screen sizes and DPIs.
*
* <p>
* This view displays two main components for color selection:
* This view displays three main components for color selection:
* <ul>
* <li><b>Hue Bar:</b> A horizontal bar at the bottom that allows the user to select the hue component of the color.
* <li><b>Saturation-Value Selector:</b> A rectangular area above the hue bar that allows the user to select the saturation and value (brightness)
* components of the color based on the selected hue.
* <li><b>Saturation-Value Selector:</b> A rectangular area above the hue bar that allows the user to select the
* saturation and value (brightness) components of the color based on the selected hue.
* <li><b>Opacity Slider:</b> An optional horizontal bar below the hue bar that allows the user to adjust
* the opacity (alpha channel) of the color.
* </ul>
*
* <p>
* The view uses {@link LinearGradient} and {@link ComposeShader} to create the color gradients for the hue bar and the
* saturation-value selector. It also uses {@link Paint} to draw the selectors (draggable handles).
*
* The view uses {@link LinearGradient} and {@link ComposeShader} to create the color gradients for the hue bar,
* opacity slider, and the saturation-value selector. It also uses {@link Paint} to draw the selectors (draggable handles).
* <p>
* The selected color can be retrieved using {@link #getColor()} and can be set using {@link #setColor(int)}.
* An {@link OnColorChangedListener} can be registered to receive notifications when the selected color changes.
*/
public class ColorPickerView extends View {
/**
* Interface definition for a callback to be invoked when the selected color changes.
*/
public interface OnColorChangedListener {
/**
* Called when the selected color has changed.
*
* Important: Callback color uses RGB format with zero alpha channel.
*
* @param color The new selected color.
*/
void onColorChanged(@ColorInt int color);
}
/** Expanded touch area for the hue bar to increase the touch-sensitive area. */
/** Expanded touch area for the hue and opacity bars to increase the touch-sensitive area. */
public static final float TOUCH_EXPANSION = dipToPixels(20f);
/** Margin between different areas of the view (saturation-value selector, hue bar, and opacity slider). */
private static final float MARGIN_BETWEEN_AREAS = dipToPixels(24);
/** Padding around the view. */
private static final float VIEW_PADDING = dipToPixels(16);
/** Height of the hue bar. */
private static final float HUE_BAR_HEIGHT = dipToPixels(12);
/** Height of the opacity slider. */
private static final float OPACITY_BAR_HEIGHT = dipToPixels(12);
/** Corner radius for the hue bar. */
private static final float HUE_CORNER_RADIUS = dipToPixels(6);
/** Corner radius for the opacity slider. */
private static final float OPACITY_CORNER_RADIUS = dipToPixels(6);
/** Radius of the selector handles. */
private static final float SELECTOR_RADIUS = dipToPixels(12);
/** Stroke width for the selector handle outlines. */
private static final float SELECTOR_STROKE_WIDTH = 8;
/**
* Hue fill radius. Use slightly smaller radius for the selector handle fill,
* Hue and opacity fill radius. Use slightly smaller radius for the selector handle fill,
* otherwise the anti-aliasing causes the fill color to bleed past the selector outline.
*/
private static final float SELECTOR_FILL_RADIUS = SELECTOR_RADIUS - SELECTOR_STROKE_WIDTH / 2;
/** Thin dark outline stroke width for the selector rings. */
private static final float SELECTOR_EDGE_STROKE_WIDTH = 1;
/** Radius for the outer edge of the selector rings, including stroke width. */
public static final float SELECTOR_EDGE_RADIUS =
SELECTOR_RADIUS + SELECTOR_STROKE_WIDTH / 2 + SELECTOR_EDGE_STROKE_WIDTH / 2;
@@ -85,6 +101,7 @@ public class ColorPickerView extends View {
@ColorInt
private static final int SELECTOR_EDGE_COLOR = Color.parseColor("#CFCFCF");
/** Precomputed array of hue colors for the hue bar (0-360 degrees). */
private static final int[] HUE_COLORS = new int[361];
static {
for (int i = 0; i < 361; i++) {
@@ -92,11 +109,16 @@ public class ColorPickerView extends View {
}
}
/** Hue bar. */
/** Paint for the hue bar. */
private final Paint huePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
/** Saturation-value selector. */
/** Paint for the opacity slider. */
private final Paint opacityPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
/** Paint for the saturation-value selector. */
private final Paint saturationValuePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
/** Draggable selector. */
/** Paint for the draggable selector handles. */
private final Paint selectorPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
{
selectorPaint.setStrokeWidth(SELECTOR_STROKE_WIDTH);
@@ -104,6 +126,10 @@ public class ColorPickerView extends View {
/** Bounds of the hue bar. */
private final RectF hueRect = new RectF();
/** Bounds of the opacity slider. */
private final RectF opacityRect = new RectF();
/** Bounds of the saturation-value selector. */
private final RectF saturationValueRect = new RectF();
@@ -112,21 +138,35 @@ public class ColorPickerView extends View {
/** Current hue value (0-360). */
private float hue = 0f;
/** Current saturation value (0-1). */
private float saturation = 1f;
/** Current value (brightness) value (0-1). */
private float value = 1f;
/** The currently selected color in RGB format with no alpha channel. */
/** Current opacity value (0-1). */
private float opacity = 1f;
/** The currently selected color, including alpha channel if opacity slider is enabled. */
@ColorInt
private int selectedColor;
/** Listener for color change events. */
private OnColorChangedListener colorChangedListener;
/** Track if we're currently dragging the hue or saturation handle. */
/** Tracks if the hue selector is being dragged. */
private boolean isDraggingHue;
/** Tracks if the saturation-value selector is being dragged. */
private boolean isDraggingSaturation;
/** Tracks if the opacity selector is being dragged. */
private boolean isDraggingOpacity;
/** Flag to enable/disable the opacity slider. */
private boolean opacitySliderEnabled = false;
public ColorPickerView(Context context) {
super(context);
}
@@ -139,12 +179,32 @@ public class ColorPickerView extends View {
super(context, attrs, defStyleAttr);
}
/**
* Enables or disables the opacity slider.
*/
public void setOpacitySliderEnabled(boolean enabled) {
if (opacitySliderEnabled != enabled) {
opacitySliderEnabled = enabled;
if (!enabled) {
opacity = 1f; // Reset to fully opaque when disabled.
updateSelectedColor();
}
updateOpacityShader();
requestLayout(); // Trigger re-measure to account for opacity slider.
invalidate();
}
}
/**
* Measures the view, ensuring a consistent aspect ratio and minimum dimensions.
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
final float DESIRED_ASPECT_RATIO = 0.8f; // height = width * 0.8
final int minWidth = Utils.dipToPixels(250);
final int minHeight = (int) (minWidth * DESIRED_ASPECT_RATIO) + (int) (HUE_BAR_HEIGHT + MARGIN_BETWEEN_AREAS);
final int minWidth = dipToPixels(250);
final int minHeight = (int) (minWidth * DESIRED_ASPECT_RATIO) + (int) (HUE_BAR_HEIGHT + MARGIN_BETWEEN_AREAS)
+ (opacitySliderEnabled ? (int) (OPACITY_BAR_HEIGHT + MARGIN_BETWEEN_AREAS) : 0);
int width = resolveSize(minWidth, widthMeasureSpec);
int height = resolveSize(minHeight, heightMeasureSpec);
@@ -154,7 +214,8 @@ public class ColorPickerView extends View {
height = Math.max(height, minHeight);
// Adjust height to maintain desired aspect ratio if possible.
final int desiredHeight = (int) (width * DESIRED_ASPECT_RATIO) + (int) (HUE_BAR_HEIGHT + MARGIN_BETWEEN_AREAS);
final int desiredHeight = (int) (width * DESIRED_ASPECT_RATIO) + (int) (HUE_BAR_HEIGHT + MARGIN_BETWEEN_AREAS)
+ (opacitySliderEnabled ? (int) (OPACITY_BAR_HEIGHT + MARGIN_BETWEEN_AREAS) : 0);
if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY) {
height = desiredHeight;
}
@@ -163,17 +224,16 @@ public class ColorPickerView extends View {
}
/**
* Called when the size of the view changes.
* This method calculates and sets the bounds of the hue bar and saturation-value selector.
* It also creates the necessary shaders for the gradients.
* Updates the view's layout when its size changes, recalculating bounds and shaders.
*/
@Override
protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
super.onSizeChanged(width, height, oldWidth, oldHeight);
// Calculate bounds with hue bar at the bottom.
// Calculate bounds with hue bar and optional opacity bar at the bottom.
final float effectiveWidth = width - (2 * VIEW_PADDING);
final float effectiveHeight = height - (2 * VIEW_PADDING) - HUE_BAR_HEIGHT - MARGIN_BETWEEN_AREAS;
final float effectiveHeight = height - (2 * VIEW_PADDING) - HUE_BAR_HEIGHT - MARGIN_BETWEEN_AREAS
- (opacitySliderEnabled ? OPACITY_BAR_HEIGHT + MARGIN_BETWEEN_AREAS : 0);
// Adjust rectangles to account for padding and density-independent dimensions.
saturationValueRect.set(
@@ -185,18 +245,28 @@ public class ColorPickerView extends View {
hueRect.set(
VIEW_PADDING,
height - VIEW_PADDING - HUE_BAR_HEIGHT,
height - VIEW_PADDING - HUE_BAR_HEIGHT - (opacitySliderEnabled ? OPACITY_BAR_HEIGHT + MARGIN_BETWEEN_AREAS : 0),
VIEW_PADDING + effectiveWidth,
height - VIEW_PADDING
height - VIEW_PADDING - (opacitySliderEnabled ? OPACITY_BAR_HEIGHT + MARGIN_BETWEEN_AREAS : 0)
);
if (opacitySliderEnabled) {
opacityRect.set(
VIEW_PADDING,
height - VIEW_PADDING - OPACITY_BAR_HEIGHT,
VIEW_PADDING + effectiveWidth,
height - VIEW_PADDING
);
}
// Update the shaders.
updateHueShader();
updateSaturationValueShader();
updateOpacityShader();
}
/**
* Updates the hue full spectrum (0-360 degrees).
* Updates the shader for the hue bar to reflect the color gradient.
*/
private void updateHueShader() {
LinearGradient hueShader = new LinearGradient(
@@ -211,8 +281,29 @@ public class ColorPickerView extends View {
}
/**
* Updates the shader for the saturation-value selector based on the currently selected hue.
* This method creates a combined shader that blends a saturation gradient with a value gradient.
* Updates the shader for the opacity slider to reflect the current RGB color with varying opacity.
*/
private void updateOpacityShader() {
if (!opacitySliderEnabled) {
opacityPaint.setShader(null);
return;
}
// Create a linear gradient for opacity from transparent to opaque, using the current RGB color.
int rgbColor = Color.HSVToColor(0, new float[]{hue, saturation, value});
LinearGradient opacityShader = new LinearGradient(
opacityRect.left, opacityRect.top,
opacityRect.right, opacityRect.top,
rgbColor & 0x00FFFFFF, // Fully transparent
rgbColor | 0xFF000000, // Fully opaque
Shader.TileMode.CLAMP
);
opacityPaint.setShader(opacityShader);
}
/**
* Updates the shader for the saturation-value selector to reflect the current hue.
*/
private void updateSaturationValueShader() {
// Create a saturation-value gradient based on the current hue.
@@ -232,7 +323,6 @@ public class ColorPickerView extends View {
);
// Create a linear gradient for the value (brightness) from white to black (vertical).
//noinspection ExtractMethodRecommender
LinearGradient valShader = new LinearGradient(
saturationValueRect.left, saturationValueRect.top,
saturationValueRect.left, saturationValueRect.bottom,
@@ -249,11 +339,7 @@ public class ColorPickerView extends View {
}
/**
* Draws the color picker view on the canvas.
* This method draws the saturation-value selector, the hue bar with rounded corners,
* and the draggable handles.
*
* @param canvas The canvas on which to draw.
* Draws the color picker components, including the saturation-value selector, hue bar, opacity slider, and their respective handles.
*/
@Override
protected void onDraw(Canvas canvas) {
@@ -263,49 +349,67 @@ public class ColorPickerView extends View {
// Draw the hue bar.
canvas.drawRoundRect(hueRect, HUE_CORNER_RADIUS, HUE_CORNER_RADIUS, huePaint);
// Draw the opacity bar if enabled.
if (opacitySliderEnabled) {
canvas.drawRoundRect(opacityRect, OPACITY_CORNER_RADIUS, OPACITY_CORNER_RADIUS, opacityPaint);
}
final float hueSelectorX = hueRect.left + (hue / 360f) * hueRect.width();
final float hueSelectorY = hueRect.centerY();
final float satSelectorX = saturationValueRect.left + saturation * saturationValueRect.width();
final float satSelectorY = saturationValueRect.top + (1 - value) * saturationValueRect.height();
// Draw the saturation and hue selector handle filled with the selected color.
// Draw the saturation and hue selector handles filled with their respective colors (fully opaque).
hsvArray[0] = hue;
final int hueHandleColor = Color.HSVToColor(0xFF, hsvArray);
final int hueHandleColor = Color.HSVToColor(0xFF, hsvArray); // Force opaque for hue handle.
final int satHandleColor = Color.HSVToColor(0xFF, new float[]{hue, saturation, value}); // Force opaque for sat-val handle.
selectorPaint.setStyle(Paint.Style.FILL_AND_STROKE);
selectorPaint.setColor(hueHandleColor);
canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_FILL_RADIUS, selectorPaint);
selectorPaint.setColor(selectedColor | 0xFF000000);
selectorPaint.setColor(satHandleColor);
canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_FILL_RADIUS, selectorPaint);
if (opacitySliderEnabled) {
final float opacitySelectorX = opacityRect.left + opacity * opacityRect.width();
final float opacitySelectorY = opacityRect.centerY();
selectorPaint.setColor(selectedColor); // Use full ARGB color to show opacity.
canvas.drawCircle(opacitySelectorX, opacitySelectorY, SELECTOR_FILL_RADIUS, selectorPaint);
}
// Draw white outlines for the handles.
selectorPaint.setColor(SELECTOR_OUTLINE_COLOR);
selectorPaint.setStyle(Paint.Style.STROKE);
selectorPaint.setStrokeWidth(SELECTOR_STROKE_WIDTH);
canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_RADIUS, selectorPaint);
canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_RADIUS, selectorPaint);
if (opacitySliderEnabled) {
final float opacitySelectorX = opacityRect.left + opacity * opacityRect.width();
final float opacitySelectorY = opacityRect.centerY();
canvas.drawCircle(opacitySelectorX, opacitySelectorY, SELECTOR_RADIUS, selectorPaint);
}
// Draw thin dark outlines for the handles at the outer edge of the white outline.
selectorPaint.setColor(SELECTOR_EDGE_COLOR);
selectorPaint.setStrokeWidth(SELECTOR_EDGE_STROKE_WIDTH);
canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_EDGE_RADIUS, selectorPaint);
canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_EDGE_RADIUS, selectorPaint);
if (opacitySliderEnabled) {
final float opacitySelectorX = opacityRect.left + opacity * opacityRect.width();
final float opacitySelectorY = opacityRect.centerY();
canvas.drawCircle(opacitySelectorX, opacitySelectorY, SELECTOR_EDGE_RADIUS, selectorPaint);
}
}
/**
* Handles touch events on the view.
* This method determines whether the touch event occurred within the hue bar or the saturation-value selector,
* updates the corresponding values (hue, saturation, value), and invalidates the view to trigger a redraw.
* <p>
* In addition to testing if the touch is within the strict rectangles, an expanded hit area (by selectorRadius)
* is used so that the draggable handles remain active even when half of the handle is outside the drawn bounds.
* Handles touch events to allow dragging of the hue, saturation-value, and opacity selectors.
*
* @param event The motion event.
* @return True if the event was handled, false otherwise.
*/
@SuppressLint("ClickableViewAccessibility") // performClick is not overridden, but not needed in this case.
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent event) {
try {
@@ -314,13 +418,19 @@ public class ColorPickerView extends View {
final int action = event.getAction();
Logger.printDebug(() -> "onTouchEvent action: " + action + " x: " + x + " y: " + y);
// Define touch expansion for the hue bar.
// Define touch expansion for the hue and opacity bars.
RectF expandedHueRect = new RectF(
hueRect.left,
hueRect.top - TOUCH_EXPANSION,
hueRect.right,
hueRect.bottom + TOUCH_EXPANSION
);
RectF expandedOpacityRect = opacitySliderEnabled ? new RectF(
opacityRect.left,
opacityRect.top - TOUCH_EXPANSION,
opacityRect.right,
opacityRect.bottom + TOUCH_EXPANSION
) : new RectF();
switch (action) {
case MotionEvent.ACTION_DOWN:
@@ -331,7 +441,10 @@ public class ColorPickerView extends View {
final float satSelectorX = saturationValueRect.left + saturation * saturationValueRect.width();
final float valSelectorY = saturationValueRect.top + (1 - value) * saturationValueRect.height();
// Create hit areas for both handles.
final float opacitySelectorX = opacitySliderEnabled ? opacityRect.left + opacity * opacityRect.width() : 0;
final float opacitySelectorY = opacitySliderEnabled ? opacityRect.centerY() : 0;
// Create hit areas for all handles.
RectF hueHitRect = new RectF(
hueSelectorX - SELECTOR_RADIUS,
hueSelectorY - SELECTOR_RADIUS,
@@ -344,14 +457,23 @@ public class ColorPickerView extends View {
satSelectorX + SELECTOR_RADIUS,
valSelectorY + SELECTOR_RADIUS
);
RectF opacityHitRect = opacitySliderEnabled ? new RectF(
opacitySelectorX - SELECTOR_RADIUS,
opacitySelectorY - SELECTOR_RADIUS,
opacitySelectorX + SELECTOR_RADIUS,
opacitySelectorY + SELECTOR_RADIUS
) : new RectF();
// Check if the touch started on a handle or within the expanded hue bar area.
// Check if the touch started on a handle or within the expanded bar areas.
if (hueHitRect.contains(x, y)) {
isDraggingHue = true;
updateHueFromTouch(x);
} else if (satValHitRect.contains(x, y)) {
isDraggingSaturation = true;
updateSaturationValueFromTouch(x, y);
} else if (opacitySliderEnabled && opacityHitRect.contains(x, y)) {
isDraggingOpacity = true;
updateOpacityFromTouch(x);
} else if (expandedHueRect.contains(x, y)) {
// Handle touch within the expanded hue bar area.
isDraggingHue = true;
@@ -359,6 +481,9 @@ public class ColorPickerView extends View {
} else if (saturationValueRect.contains(x, y)) {
isDraggingSaturation = true;
updateSaturationValueFromTouch(x, y);
} else if (opacitySliderEnabled && expandedOpacityRect.contains(x, y)) {
isDraggingOpacity = true;
updateOpacityFromTouch(x);
}
break;
@@ -368,6 +493,8 @@ public class ColorPickerView extends View {
updateHueFromTouch(x);
} else if (isDraggingSaturation) {
updateSaturationValueFromTouch(x, y);
} else if (isDraggingOpacity) {
updateOpacityFromTouch(x);
}
break;
@@ -375,6 +502,7 @@ public class ColorPickerView extends View {
case MotionEvent.ACTION_CANCEL:
isDraggingHue = false;
isDraggingSaturation = false;
isDraggingOpacity = false;
break;
}
} catch (Exception ex) {
@@ -385,9 +513,7 @@ public class ColorPickerView extends View {
}
/**
* Updates the hue value based on touch position, clamping to valid range.
*
* @param x The x-coordinate of the touch position.
* Updates the hue value based on a touch event.
*/
private void updateHueFromTouch(float x) {
// Clamp x to the hue rectangle bounds.
@@ -399,14 +525,12 @@ public class ColorPickerView extends View {
hue = updatedHue;
updateSaturationValueShader();
updateOpacityShader();
updateSelectedColor();
}
/**
* Updates saturation and value based on touch position, clamping to valid range.
*
* @param x The x-coordinate of the touch position.
* @param y The y-coordinate of the touch position.
* Updates the saturation and value based on a touch event.
*/
private void updateSaturationValueFromTouch(float x, float y) {
// Clamp x and y to the saturation-value rectangle bounds.
@@ -421,14 +545,34 @@ public class ColorPickerView extends View {
}
saturation = updatedSaturation;
value = updatedValue;
updateOpacityShader();
updateSelectedColor();
}
/**
* Updates the selected color and notifies listeners.
* Updates the opacity value based on a touch event.
*/
private void updateOpacityFromTouch(float x) {
if (!opacitySliderEnabled) {
return;
}
final float clampedX = Utils.clamp(x, opacityRect.left, opacityRect.right);
final float updatedOpacity = (clampedX - opacityRect.left) / opacityRect.width();
if (opacity == updatedOpacity) {
return;
}
opacity = updatedOpacity;
updateSelectedColor();
}
/**
* Updates the selected color based on the current hue, saturation, value, and opacity.
*/
private void updateSelectedColor() {
final int updatedColor = Color.HSVToColor(0, new float[]{hue, saturation, value});
final int rgbColor = Color.HSVToColor(0, new float[]{hue, saturation, value});
final int updatedColor = opacitySliderEnabled
? (rgbColor & 0x00FFFFFF) | (((int) (opacity * 255)) << 24)
: (rgbColor & 0x00FFFFFF) | 0xFF000000;
if (selectedColor != updatedColor) {
selectedColor = updatedColor;
@@ -444,19 +588,16 @@ public class ColorPickerView extends View {
}
/**
* Sets the currently selected color.
*
* @param color The color to set in either ARGB or RGB format.
* Sets the selected color, updating the hue, saturation, value and opacity sliders accordingly.
*/
public void setColor(@ColorInt int color) {
color &= 0x00FFFFFF;
if (selectedColor == color) {
return;
}
// Update the selected color.
selectedColor = color;
Logger.printDebug(() -> "setColor: " + getColorString(selectedColor));
Logger.printDebug(() -> "setColor: " + getColorString(selectedColor, opacitySliderEnabled));
// Convert the ARGB color to HSV values.
float[] hsv = new float[3];
@@ -466,9 +607,11 @@ public class ColorPickerView extends View {
hue = hsv[0];
saturation = hsv[1];
value = hsv[2];
opacity = opacitySliderEnabled ? ((color >> 24) & 0xFF) / 255f : 1f;
// Update the saturation-value shader based on the new hue.
updateSaturationValueShader();
updateOpacityShader();
// Notify the listener if it's set.
if (colorChangedListener != null) {
@@ -481,8 +624,6 @@ public class ColorPickerView extends View {
/**
* Gets the currently selected color.
*
* @return The selected color in RGB format with no alpha channel.
*/
@ColorInt
public int getColor() {
@@ -490,9 +631,7 @@ public class ColorPickerView extends View {
}
/**
* Sets the listener to be notified when the selected color changes.
*
* @param listener The listener to set.
* Sets a listener to be notified when the selected color changes.
*/
public void setOnColorChangedListener(OnColorChangedListener listener) {
colorChangedListener = listener;

View File

@@ -0,0 +1,34 @@
package app.revanced.extension.shared.settings.preference;
import android.content.Context;
import android.util.AttributeSet;
/**
* Extended ColorPickerPreference that enables the opacity slider for color selection.
*/
@SuppressWarnings("unused")
public class ColorPickerWithOpacitySliderPreference extends ColorPickerPreference {
public ColorPickerWithOpacitySliderPreference(Context context) {
super(context);
init();
}
public ColorPickerWithOpacitySliderPreference(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public ColorPickerWithOpacitySliderPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
/**
* Initialize the preference with opacity slider enabled.
*/
private void init() {
// Enable the opacity slider for alpha channel support.
setOpacitySliderEnabled(true);
}
}

View File

@@ -1,5 +1,7 @@
package app.revanced.extension.shared.settings.preference;
import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
import android.app.Dialog;
import android.content.Context;
import android.os.Bundle;
@@ -9,18 +11,86 @@ import android.util.Pair;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.*;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.ui.CustomDialog;
/**
* A custom ListPreference that uses a styled custom dialog with a custom checkmark indicator.
* A custom ListPreference that uses a styled custom dialog with a custom checkmark indicator,
* supports a static summary and highlighted entries for search functionality.
*/
@SuppressWarnings({"unused", "deprecation"})
public class CustomDialogListPreference extends ListPreference {
public static final int ID_REVANCED_CHECK_ICON =
getResourceIdentifierOrThrow("revanced_check_icon", "id");
public static final int ID_REVANCED_CHECK_ICON_PLACEHOLDER =
getResourceIdentifierOrThrow("revanced_check_icon_placeholder", "id");
public static final int ID_REVANCED_ITEM_TEXT =
getResourceIdentifierOrThrow("revanced_item_text", "id");
public static final int LAYOUT_REVANCED_CUSTOM_LIST_ITEM_CHECKED =
getResourceIdentifierOrThrow("revanced_custom_list_item_checked", "layout");
private String staticSummary = null;
private CharSequence[] highlightedEntriesForDialog = null;
/**
* Set a static summary that will not be overwritten by value changes.
*/
public void setStaticSummary(String summary) {
this.staticSummary = summary;
}
/**
* Returns the static summary if set, otherwise null.
*/
@Nullable
public String getStaticSummary() {
return staticSummary;
}
/**
* Always return static summary if set.
*/
@Override
public CharSequence getSummary() {
if (staticSummary != null) {
return staticSummary;
}
return super.getSummary();
}
/**
* Sets highlighted entries for display in the dialog.
* These entries are used only for the current dialog and are automatically cleared.
*/
public void setHighlightedEntriesForDialog(CharSequence[] highlightedEntries) {
this.highlightedEntriesForDialog = highlightedEntries;
}
/**
* Clears highlighted entries after the dialog is closed.
*/
public void clearHighlightedEntriesForDialog() {
this.highlightedEntriesForDialog = null;
}
/**
* Returns entries for display in the dialog.
* If highlighted entries exist, they are used; otherwise, the original entries are returned.
*/
private CharSequence[] getEntriesForDialog() {
return highlightedEntriesForDialog != null ? highlightedEntriesForDialog : getEntries();
}
/**
* Custom ArrayAdapter to handle checkmark visibility.
*/
@@ -35,8 +105,10 @@ public class CustomDialogListPreference extends ListPreference {
final CharSequence[] entryValues;
String selectedValue;
public ListPreferenceArrayAdapter(Context context, int resource, CharSequence[] entries,
CharSequence[] entryValues, String selectedValue) {
public ListPreferenceArrayAdapter(Context context, int resource,
CharSequence[] entries,
CharSequence[] entryValues,
String selectedValue) {
super(context, resource, entries);
this.layoutResourceId = resource;
this.entryValues = entryValues;
@@ -53,19 +125,16 @@ public class CustomDialogListPreference extends ListPreference {
LayoutInflater inflater = LayoutInflater.from(getContext());
view = inflater.inflate(layoutResourceId, parent, false);
holder = new SubViewDataContainer();
holder.checkIcon = view.findViewById(Utils.getResourceIdentifier(
"revanced_check_icon", "id"));
holder.placeholder = view.findViewById(Utils.getResourceIdentifier(
"revanced_check_icon_placeholder", "id"));
holder.itemText = view.findViewById(Utils.getResourceIdentifier(
"revanced_item_text", "id"));
holder.checkIcon = view.findViewById(ID_REVANCED_CHECK_ICON);
holder.placeholder = view.findViewById(ID_REVANCED_CHECK_ICON_PLACEHOLDER);
holder.itemText = view.findViewById(ID_REVANCED_ITEM_TEXT);
view.setTag(holder);
} else {
holder = (SubViewDataContainer) view.getTag();
}
// Set text.
holder.itemText.setText(getItem(position));
CharSequence itemText = getItem(position);
holder.itemText.setText(itemText);
holder.itemText.setTextColor(Utils.getAppForegroundColor());
// Show or hide checkmark and placeholder.
@@ -103,6 +172,9 @@ public class CustomDialogListPreference extends ListPreference {
protected void showDialog(Bundle state) {
Context context = getContext();
CharSequence[] entriesToShow = getEntriesForDialog();
CharSequence[] entryValues = getEntryValues();
// Create ListView.
ListView listView = new ListView(context);
listView.setId(android.R.id.list);
@@ -111,9 +183,9 @@ public class CustomDialogListPreference extends ListPreference {
// Create custom adapter for the ListView.
ListPreferenceArrayAdapter adapter = new ListPreferenceArrayAdapter(
context,
Utils.getResourceIdentifier("revanced_custom_list_item_checked", "layout"),
getEntries(),
getEntryValues(),
LAYOUT_REVANCED_CUSTOM_LIST_ITEM_CHECKED,
entriesToShow,
entryValues,
getValue()
);
listView.setAdapter(adapter);
@@ -121,7 +193,6 @@ public class CustomDialogListPreference extends ListPreference {
// Set checked item.
String currentValue = getValue();
if (currentValue != null) {
CharSequence[] entryValues = getEntryValues();
for (int i = 0, length = entryValues.length; i < length; i++) {
if (currentValue.equals(entryValues[i].toString())) {
listView.setItemChecked(i, true);
@@ -132,19 +203,23 @@ public class CustomDialogListPreference extends ListPreference {
}
// Create the custom dialog without OK button.
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context,
getTitle() != null ? getTitle().toString() : "",
null,
null,
null, // No OK button text.
null, // No OK button action.
() -> {}, // Cancel button action (just dismiss).
null,
null,
this::clearHighlightedEntriesForDialog, // Cancel button action.
null,
null,
true
);
Dialog dialog = dialogPair.first;
// Add a listener to clear when the dialog is closed in any way.
dialog.setOnDismissListener(dialogInterface -> clearHighlightedEntriesForDialog());
// Add the ListView to the main layout.
LinearLayout mainLayout = dialogPair.second;
LinearLayout.LayoutParams listViewParams = new LinearLayout.LayoutParams(
@@ -156,16 +231,28 @@ public class CustomDialogListPreference extends ListPreference {
// Handle item click to select value and dismiss dialog.
listView.setOnItemClickListener((parent, view, position, id) -> {
String selectedValue = getEntryValues()[position].toString();
String selectedValue = entryValues[position].toString();
if (callChangeListener(selectedValue)) {
setValue(selectedValue);
// Update summaries from the original entries (without highlighting).
if (staticSummary == null) {
CharSequence[] originalEntries = getEntries();
if (originalEntries != null && position < originalEntries.length) {
setSummary(originalEntries[position]);
}
}
adapter.setSelectedValue(selectedValue);
adapter.notifyDataSetChanged();
}
dialogPair.first.dismiss();
// Clear highlighted entries before closing.
clearHighlightedEntriesForDialog();
dialog.dismiss();
});
// Show the dialog.
dialogPair.first.show();
dialog.show();
}
}

View File

@@ -1,9 +1,8 @@
package app.revanced.extension.youtube.settings.preference;
package app.revanced.extension.shared.settings.preference;
import android.content.Context;
import android.util.AttributeSet;
import android.preference.Preference;
import app.revanced.extension.shared.settings.preference.LogBufferManager;
/**
* A custom preference that triggers exporting ReVanced debug logs to the clipboard when clicked.

View File

@@ -1,7 +1,6 @@
package app.revanced.extension.shared.settings.preference;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.shared.Utils.dipToPixels;
import android.app.Dialog;
import android.content.Context;
@@ -10,21 +9,17 @@ import android.os.Bundle;
import android.preference.EditTextPreference;
import android.preference.Preference;
import android.text.InputType;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Pair;
import android.util.TypedValue;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.graphics.Color;
import android.graphics.drawable.ShapeDrawable;
import android.graphics.drawable.shapes.RoundRectShape;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.shared.ui.CustomDialog;
@SuppressWarnings({"unused", "deprecation"})
public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener {
@@ -82,7 +77,7 @@ public class ImportExportPreference extends EditTextPreference implements Prefer
EditText editText = getEditText();
// Create a custom dialog with the EditText.
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context,
str("revanced_pref_import_export_title"), // Title.
null, // No message (EditText replaces it).
@@ -98,6 +93,20 @@ public class ImportExportPreference extends EditTextPreference implements Prefer
true // Dismiss dialog when onNeutralClick.
);
// If there are no settings yet, then show the on screen keyboard and bring focus to
// the edit text. This makes it easier to paste saved settings after a reinstall.
dialogPair.first.setOnShowListener(dialogInterface -> {
if (existingSettings.isEmpty()) {
editText.postDelayed(() -> {
editText.requestFocus();
InputMethodManager inputMethodManager = (InputMethodManager)
editText.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
inputMethodManager.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT);
}, 100);
}
});
// Show the dialog.
dialogPair.first.show();
} catch (Exception ex) {

View File

@@ -17,6 +17,7 @@ import android.os.Handler;
import android.os.Looper;
import android.preference.Preference;
import android.util.AttributeSet;
import android.view.Gravity;
import android.view.View;
import android.view.Window;
import android.webkit.WebView;
@@ -228,10 +229,10 @@ class WebViewDialog extends Dialog {
setContentView(mainLayout);
// Set dialog window attributes
// Set dialog window attributes.
Window window = getWindow();
if (window != null) {
Utils.setDialogWindowParameters(window);
Utils.setDialogWindowParameters(window, Gravity.CENTER, 0, 90, false);
}
}

View File

@@ -16,8 +16,8 @@ import androidx.annotation.Nullable;
import java.util.Objects;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.shared.ui.CustomDialog;
@SuppressWarnings({"unused", "deprecation"})
public class ResettableEditTextPreference extends EditTextPreference {
@@ -66,7 +66,7 @@ public class ResettableEditTextPreference extends EditTextPreference {
// Create custom dialog.
String neutralButtonText = (setting != null) ? str("revanced_settings_reset") : null;
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context,
getTitle() != null ? getTitle().toString() : "", // Title.
null, // Message is replaced by EditText.

View File

@@ -0,0 +1,150 @@
package app.revanced.extension.shared.settings.preference;
import android.annotation.SuppressLint;
import android.app.Dialog;
import android.graphics.Insets;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.preference.Preference;
import android.preference.PreferenceScreen;
import android.util.TypedValue;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowInsets;
import android.widget.TextView;
import android.widget.Toolbar;
import androidx.annotation.Nullable;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.BaseActivityHook;
@SuppressWarnings({"deprecation", "NewApi"})
public class ToolbarPreferenceFragment extends AbstractPreferenceFragment {
/**
* Sets toolbar for all nested preference screens.
*/
protected void setPreferenceScreenToolbar(PreferenceScreen parentScreen) {
for (int i = 0, count = parentScreen.getPreferenceCount(); i < count; i++) {
Preference childPreference = parentScreen.getPreference(i);
if (childPreference instanceof PreferenceScreen) {
// Recursively set sub preferences.
setPreferenceScreenToolbar((PreferenceScreen) childPreference);
childPreference.setOnPreferenceClickListener(
childScreen -> {
Dialog preferenceScreenDialog = ((PreferenceScreen) childScreen).getDialog();
ViewGroup rootView = (ViewGroup) preferenceScreenDialog
.findViewById(android.R.id.content)
.getParent();
// Allow package-specific background customization.
customizeDialogBackground(rootView);
// Fix the system navigation bar color for submenus.
setNavigationBarColor(preferenceScreenDialog.getWindow());
// Fix edge-to-edge screen with Android 15 and YT 19.45+
// https://developer.android.com/develop/ui/views/layout/edge-to-edge#system-bars-insets
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
rootView.setOnApplyWindowInsetsListener((v, insets) -> {
Insets statusInsets = insets.getInsets(WindowInsets.Type.statusBars());
Insets navInsets = insets.getInsets(WindowInsets.Type.navigationBars());
Insets cutoutInsets = insets.getInsets(WindowInsets.Type.displayCutout());
// Apply padding for display cutout in landscape.
int leftPadding = cutoutInsets.left;
int rightPadding = cutoutInsets.right;
int topPadding = statusInsets.top;
int bottomPadding = navInsets.bottom;
v.setPadding(leftPadding, topPadding, rightPadding, bottomPadding);
return insets;
});
}
Toolbar toolbar = new Toolbar(childScreen.getContext());
toolbar.setTitle(childScreen.getTitle());
toolbar.setNavigationIcon(getBackButtonDrawable());
toolbar.setNavigationOnClickListener(view -> preferenceScreenDialog.dismiss());
final int margin = Utils.dipToPixels(16);
toolbar.setTitleMargin(margin, 0, margin, 0);
TextView toolbarTextView = Utils.getChildView(toolbar,
true, TextView.class::isInstance);
if (toolbarTextView != null) {
toolbarTextView.setTextColor(Utils.getAppForegroundColor());
toolbarTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
}
// Allow package-specific toolbar customization.
customizeToolbar(toolbar);
// Allow package-specific post-toolbar setup.
onPostToolbarSetup(toolbar, preferenceScreenDialog);
rootView.addView(toolbar, 0);
return false;
}
);
}
}
}
/**
* Sets the system navigation bar color for the activity.
* Applies the background color obtained from {@link Utils#getAppBackgroundColor()} to the navigation bar.
* For Android 10 (API 29) and above, enforces navigation bar contrast to ensure visibility.
*/
public static void setNavigationBarColor(@Nullable Window window) {
if (window == null) {
Logger.printDebug(() -> "Cannot set navigation bar color, window is null");
return;
}
window.setNavigationBarColor(Utils.getAppBackgroundColor());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.setNavigationBarContrastEnforced(true);
}
}
/**
* Returns the drawable for the back button.
*/
@SuppressLint("UseCompatLoadingForDrawables")
public static Drawable getBackButtonDrawable() {
final int backButtonResource = Utils.getResourceIdentifierOrThrow(
"revanced_settings_toolbar_arrow_left", "drawable");
Drawable drawable = Utils.getContext().getResources().getDrawable(backButtonResource);
customizeBackButtonDrawable(drawable);
return drawable;
}
/**
* Customizes the back button drawable.
*/
protected static void customizeBackButtonDrawable(Drawable drawable) {
drawable.setTint(Utils.getAppForegroundColor());
}
/**
* Allows subclasses to customize the dialog's root view background.
*/
protected void customizeDialogBackground(ViewGroup rootView) {
rootView.setBackgroundColor(Utils.getAppBackgroundColor());
}
/**
* Allows subclasses to customize the toolbar.
*/
protected void customizeToolbar(Toolbar toolbar) {
BaseActivityHook.setToolbarLayoutParams(toolbar);
}
/**
* Allows subclasses to perform actions after toolbar setup.
*/
protected void onPostToolbarSetup(Toolbar toolbar, Dialog preferenceScreenDialog) {}
}

View File

@@ -1,4 +1,4 @@
package app.revanced.extension.youtube.settings.preference;
package app.revanced.extension.shared.settings.preference;
import android.content.Context;
import android.content.Intent;

View File

@@ -0,0 +1,365 @@
package app.revanced.extension.shared.settings.search;
import android.graphics.Color;
import android.preference.ListPreference;
import android.preference.Preference;
import android.preference.SwitchPreference;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.style.BackgroundColorSpan;
import androidx.annotation.ColorInt;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.preference.ColorPickerPreference;
import app.revanced.extension.shared.settings.preference.CustomDialogListPreference;
import app.revanced.extension.shared.settings.preference.UrlLinkPreference;
/**
* Abstract base class for search result items, defining common fields and behavior.
*/
public abstract class BaseSearchResultItem {
// Enum to represent view types.
public enum ViewType {
REGULAR,
SWITCH,
LIST,
COLOR_PICKER,
GROUP_HEADER,
NO_RESULTS,
URL_LINK;
// Get the corresponding layout resource ID.
public int getLayoutResourceId() {
return switch (this) {
case REGULAR, URL_LINK -> getResourceIdentifier("revanced_preference_search_result_regular");
case SWITCH -> getResourceIdentifier("revanced_preference_search_result_switch");
case LIST -> getResourceIdentifier("revanced_preference_search_result_list");
case COLOR_PICKER -> getResourceIdentifier("revanced_preference_search_result_color");
case GROUP_HEADER -> getResourceIdentifier("revanced_preference_search_result_group_header");
case NO_RESULTS -> getResourceIdentifier("revanced_preference_search_no_result");
};
}
private static int getResourceIdentifier(String name) {
// Placeholder for actual resource identifier retrieval.
return Utils.getResourceIdentifierOrThrow(name, "layout");
}
}
final String navigationPath;
final List<String> navigationKeys;
final ViewType preferenceType;
CharSequence highlightedTitle;
CharSequence highlightedSummary;
boolean highlightingApplied;
BaseSearchResultItem(String navPath, List<String> navKeys, ViewType type) {
this.navigationPath = navPath;
this.navigationKeys = new ArrayList<>(navKeys != null ? navKeys : Collections.emptyList());
this.preferenceType = type;
this.highlightedTitle = "";
this.highlightedSummary = "";
this.highlightingApplied = false;
}
abstract boolean matchesQuery(String query);
abstract void applyHighlighting(Pattern queryPattern);
abstract void clearHighlighting();
// Shared method for highlighting text with search query.
protected static CharSequence highlightSearchQuery(CharSequence text, Pattern queryPattern) {
if (TextUtils.isEmpty(text)) return text;
final int adjustedColor = Utils.adjustColorBrightness(
Utils.getAppBackgroundColor(), 0.95f, 1.20f);
BackgroundColorSpan highlightSpan = new BackgroundColorSpan(adjustedColor);
SpannableStringBuilder spannable = new SpannableStringBuilder(text);
Matcher matcher = queryPattern.matcher(text);
while (matcher.find()) {
spannable.setSpan(highlightSpan, matcher.start(), matcher.end(),
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
}
return spannable;
}
/**
* Search result item for group headers (navigation path only).
*/
public static class GroupHeaderItem extends BaseSearchResultItem {
GroupHeaderItem(String navPath, List<String> navKeys) {
super(navPath, navKeys, ViewType.GROUP_HEADER);
this.highlightedTitle = navPath;
}
@Override
boolean matchesQuery(String query) {
return false; // Headers are not directly searchable.
}
@Override
void applyHighlighting(Pattern queryPattern) {}
@Override
void clearHighlighting() {}
}
/**
* Search result item for preferences, handling type-specific data and search text.
*/
@SuppressWarnings("deprecation")
public static class PreferenceSearchItem extends BaseSearchResultItem {
public final Preference preference;
final String searchableText;
final CharSequence originalTitle;
final CharSequence originalSummary;
final CharSequence originalSummaryOn;
final CharSequence originalSummaryOff;
final CharSequence[] originalEntries;
private CharSequence[] highlightedEntries;
private boolean entriesHighlightingApplied;
@ColorInt
private int color;
// Store last applied highlighting pattern to reapply when needed.
Pattern lastQueryPattern;
PreferenceSearchItem(Preference pref, String navPath, List<String> navKeys) {
super(navPath, navKeys, determineType(pref));
this.preference = pref;
this.originalTitle = pref.getTitle() != null ? pref.getTitle() : "";
this.originalSummary = pref.getSummary();
this.highlightedTitle = this.originalTitle;
this.highlightedSummary = this.originalSummary != null ? this.originalSummary : "";
this.color = 0;
this.lastQueryPattern = null;
// Initialize type-specific fields.
FieldInitializationResult result = initTypeSpecificFields(pref);
this.originalSummaryOn = result.summaryOn;
this.originalSummaryOff = result.summaryOff;
this.originalEntries = result.entries;
// Build searchable text.
this.searchableText = buildSearchableText(pref);
}
private static class FieldInitializationResult {
CharSequence summaryOn = null;
CharSequence summaryOff = null;
CharSequence[] entries = null;
}
private static ViewType determineType(Preference pref) {
if (pref instanceof SwitchPreference) return ViewType.SWITCH;
if (pref instanceof ListPreference) return ViewType.LIST;
if (pref instanceof ColorPickerPreference) return ViewType.COLOR_PICKER;
if (pref instanceof UrlLinkPreference) return ViewType.URL_LINK;
if ("no_results_placeholder".equals(pref.getKey())) return ViewType.NO_RESULTS;
return ViewType.REGULAR;
}
private FieldInitializationResult initTypeSpecificFields(Preference pref) {
FieldInitializationResult result = new FieldInitializationResult();
if (pref instanceof SwitchPreference switchPref) {
result.summaryOn = switchPref.getSummaryOn();
result.summaryOff = switchPref.getSummaryOff();
} else if (pref instanceof ColorPickerPreference colorPref) {
String colorString = colorPref.getText();
this.color = TextUtils.isEmpty(colorString) ? 0 : Color.parseColor(colorString);
} else if (pref instanceof ListPreference listPref) {
result.entries = listPref.getEntries();
if (result.entries != null) {
this.highlightedEntries = new CharSequence[result.entries.length];
System.arraycopy(result.entries, 0, this.highlightedEntries, 0, result.entries.length);
}
}
this.entriesHighlightingApplied = false;
return result;
}
private String buildSearchableText(Preference pref) {
StringBuilder searchBuilder = new StringBuilder();
String key = pref.getKey();
String normalizedKey = "";
if (key != null) {
// Normalize preference key by removing the common "revanced_" prefix
// so that users can search by the meaningful part only.
normalizedKey = key.startsWith("revanced_")
? key.substring("revanced_".length())
: key;
}
appendText(searchBuilder, normalizedKey);
appendText(searchBuilder, originalTitle);
appendText(searchBuilder, originalSummary);
// Add type-specific searchable content.
if (pref instanceof ListPreference) {
if (originalEntries != null) {
for (CharSequence entry : originalEntries) {
appendText(searchBuilder, entry);
}
}
} else if (pref instanceof SwitchPreference) {
appendText(searchBuilder, originalSummaryOn);
appendText(searchBuilder, originalSummaryOff);
} else if (pref instanceof ColorPickerPreference) {
appendText(searchBuilder, ColorPickerPreference.getColorString(color, false));
}
// Include navigation path in searchable text.
appendText(searchBuilder, navigationPath);
return searchBuilder.toString();
}
private void appendText(StringBuilder builder, CharSequence text) {
if (!TextUtils.isEmpty(text)) {
if (builder.length() > 0) builder.append(" ");
builder.append(Utils.removePunctuationToLowercase(text));
}
}
/**
* Gets the current effective summary for this preference, considering state-dependent summaries.
*/
public CharSequence getCurrentEffectiveSummary() {
if (preference instanceof CustomDialogListPreference customPref) {
String staticSum = customPref.getStaticSummary();
if (staticSum != null) {
return staticSum;
}
}
if (preference instanceof SwitchPreference switchPref) {
boolean currentState = switchPref.isChecked();
return currentState
? (originalSummaryOn != null ? originalSummaryOn :
originalSummary != null ? originalSummary : "")
: (originalSummaryOff != null ? originalSummaryOff :
originalSummary != null ? originalSummary : "");
} else if (preference instanceof ListPreference listPref) {
String value = listPref.getValue();
CharSequence[] entries = listPref.getEntries();
CharSequence[] entryValues = listPref.getEntryValues();
if (value != null && entries != null && entryValues != null) {
for (int i = 0, length = entries.length; i < length; i++) {
if (value.equals(entryValues[i].toString())) {
return originalEntries != null && i < originalEntries.length && originalEntries[i] != null
? originalEntries[i]
: originalSummary != null ? originalSummary : "";
}
}
}
return originalSummary != null ? originalSummary : "";
}
return originalSummary != null ? originalSummary : "";
}
/**
* Checks if this search result item matches the provided query.
* Uses case-insensitive matching against the searchable text.
*/
@Override
boolean matchesQuery(String query) {
return searchableText.contains(Utils.removePunctuationToLowercase(query));
}
/**
* Get highlighted entries to show in dialog.
*/
public CharSequence[] getHighlightedEntries() {
return highlightedEntries;
}
/**
* Whether highlighting is applied to entries.
*/
public boolean isEntriesHighlightingApplied() {
return entriesHighlightingApplied;
}
/**
* Highlights the search query in the title and summary.
*/
@Override
void applyHighlighting(Pattern queryPattern) {
this.lastQueryPattern = queryPattern;
// Highlight the title.
highlightedTitle = highlightSearchQuery(originalTitle, queryPattern);
// Get the current effective summary and highlight it.
CharSequence currentSummary = getCurrentEffectiveSummary();
highlightedSummary = highlightSearchQuery(currentSummary, queryPattern);
// Highlight the entries.
if (preference instanceof ListPreference && originalEntries != null) {
highlightedEntries = new CharSequence[originalEntries.length];
for (int i = 0, length = originalEntries.length; i < length; i++) {
if (originalEntries[i] != null) {
highlightedEntries[i] = highlightSearchQuery(originalEntries[i], queryPattern);
} else {
highlightedEntries[i] = null;
}
}
entriesHighlightingApplied = true;
}
highlightingApplied = true;
}
/**
* Clears all search query highlighting and restores original state completely.
*/
@Override
void clearHighlighting() {
if (!highlightingApplied) return;
// Restore original title.
highlightedTitle = originalTitle;
// Restore current effective summary without highlighting.
highlightedSummary = getCurrentEffectiveSummary();
// Restore original entries.
if (originalEntries != null && highlightedEntries != null) {
System.arraycopy(originalEntries, 0, highlightedEntries, 0,
Math.min(originalEntries.length, highlightedEntries.length));
}
entriesHighlightingApplied = false;
highlightingApplied = false;
lastQueryPattern = null;
}
/**
* Refreshes highlighting for dynamic summaries (like switch preferences).
* Should be called when the preference state changes.
*/
public void refreshHighlighting() {
if (highlightingApplied && lastQueryPattern != null) {
CharSequence currentSummary = getCurrentEffectiveSummary();
highlightedSummary = highlightSearchQuery(currentSummary, lastQueryPattern);
}
}
public void setColor(int newColor) {
this.color = newColor;
}
@ColorInt
public int getColor() {
return color;
}
}
}

View File

@@ -0,0 +1,621 @@
package app.revanced.extension.shared.settings.search;
import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
import static app.revanced.extension.shared.settings.search.BaseSearchViewController.DRAWABLE_REVANCED_SETTINGS_SEARCH_ICON;
import android.animation.AnimatorSet;
import android.animation.ArgbEvaluator;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.preference.ListPreference;
import android.preference.Preference;
import android.preference.PreferenceGroup;
import android.preference.PreferenceScreen;
import android.preference.SwitchPreference;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AbsListView;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.ListAdapter;
import android.widget.ListView;
import android.widget.Switch;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.lang.reflect.Method;
import java.util.List;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.preference.ColorPickerPreference;
import app.revanced.extension.shared.settings.preference.CustomDialogListPreference;
import app.revanced.extension.shared.settings.preference.UrlLinkPreference;
import app.revanced.extension.shared.ui.ColorDot;
/**
* Abstract adapter for displaying search results in overlay ListView with ViewHolder pattern.
*/
@SuppressWarnings("deprecation")
public abstract class BaseSearchResultsAdapter extends ArrayAdapter<BaseSearchResultItem> {
protected final LayoutInflater inflater;
protected final BaseSearchViewController.BasePreferenceFragment fragment;
protected final BaseSearchViewController searchViewController;
protected AnimatorSet currentAnimator;
protected abstract PreferenceScreen getMainPreferenceScreen();
protected static final int BLINK_DURATION = 400;
protected static final int PAUSE_BETWEEN_BLINKS = 100;
protected static final int ID_PREFERENCE_TITLE = getResourceIdentifierOrThrow(
"preference_title", "id");
protected static final int ID_PREFERENCE_SUMMARY = getResourceIdentifierOrThrow(
"preference_summary", "id");
protected static final int ID_PREFERENCE_PATH = getResourceIdentifierOrThrow(
"preference_path", "id");
protected static final int ID_PREFERENCE_SWITCH = getResourceIdentifierOrThrow(
"preference_switch", "id");
protected static final int ID_PREFERENCE_COLOR_DOT = getResourceIdentifierOrThrow(
"preference_color_dot", "id");
protected static class RegularViewHolder {
TextView titleView;
TextView summaryView;
}
protected static class SwitchViewHolder {
TextView titleView;
TextView summaryView;
Switch switchWidget;
}
protected static class ColorViewHolder {
TextView titleView;
TextView summaryView;
View colorDot;
}
protected static class GroupHeaderViewHolder {
TextView pathView;
}
protected static class NoResultsViewHolder {
TextView titleView;
TextView summaryView;
ImageView iconView;
}
public BaseSearchResultsAdapter(Context context, List<BaseSearchResultItem> items,
BaseSearchViewController.BasePreferenceFragment fragment,
BaseSearchViewController searchViewController) {
super(context, 0, items);
this.inflater = LayoutInflater.from(context);
this.fragment = fragment;
this.searchViewController = searchViewController;
}
@Override
public int getItemViewType(int position) {
BaseSearchResultItem item = getItem(position);
return item == null ? 0 : item.preferenceType.ordinal();
}
@Override
public int getViewTypeCount() {
return BaseSearchResultItem.ViewType.values().length;
}
@NonNull
@Override
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
BaseSearchResultItem item = getItem(position);
if (item == null) return new View(getContext());
// Use the ViewType enum.
BaseSearchResultItem.ViewType viewType = item.preferenceType;
// Create or reuse preference view based on type.
return createPreferenceView(item, convertView, viewType, parent);
}
@Override
public boolean isEnabled(int position) {
BaseSearchResultItem item = getItem(position);
// Disable for NO_RESULTS items to prevent ripple/selection.
return item != null && item.preferenceType != BaseSearchResultItem.ViewType.NO_RESULTS;
}
/**
* Creates or reuses a view for the given SearchResultItem.
* <p>
* Thanks to {@link #getItemViewType(int)} and {@link #getViewTypeCount()}, ListView knows
* how many different row types exist and keeps a separate "recycling pool" for each.
* That means convertView passed here is ALWAYS of the correct type for this position.
* So only need to check if (view == null), and if so inflate a new layout and create the proper ViewHolder.
*/
protected View createPreferenceView(BaseSearchResultItem item, View convertView,
BaseSearchResultItem.ViewType viewType, ViewGroup parent) {
View view = convertView;
if (view == null) {
view = inflateViewForType(viewType, parent);
createViewHolderForType(view, viewType);
}
// Retrieve the cached ViewHolder.
Object holder = view.getTag();
bindDataToViewHolder(item, holder, viewType, view);
return view;
}
protected View inflateViewForType(BaseSearchResultItem.ViewType viewType, ViewGroup parent) {
return inflater.inflate(viewType.getLayoutResourceId(), parent, false);
}
protected void createViewHolderForType(View view, BaseSearchResultItem.ViewType viewType) {
switch (viewType) {
case REGULAR, LIST, URL_LINK -> {
RegularViewHolder regularHolder = new RegularViewHolder();
regularHolder.titleView = view.findViewById(ID_PREFERENCE_TITLE);
regularHolder.summaryView = view.findViewById(ID_PREFERENCE_SUMMARY);
view.setTag(regularHolder);
}
case SWITCH -> {
SwitchViewHolder switchHolder = new SwitchViewHolder();
switchHolder.titleView = view.findViewById(ID_PREFERENCE_TITLE);
switchHolder.summaryView = view.findViewById(ID_PREFERENCE_SUMMARY);
switchHolder.switchWidget = view.findViewById(ID_PREFERENCE_SWITCH);
view.setTag(switchHolder);
}
case COLOR_PICKER -> {
ColorViewHolder colorHolder = new ColorViewHolder();
colorHolder.titleView = view.findViewById(ID_PREFERENCE_TITLE);
colorHolder.summaryView = view.findViewById(ID_PREFERENCE_SUMMARY);
colorHolder.colorDot = view.findViewById(ID_PREFERENCE_COLOR_DOT);
view.setTag(colorHolder);
}
case GROUP_HEADER -> {
GroupHeaderViewHolder groupHolder = new GroupHeaderViewHolder();
groupHolder.pathView = view.findViewById(ID_PREFERENCE_PATH);
view.setTag(groupHolder);
}
case NO_RESULTS -> {
NoResultsViewHolder noResultsHolder = new NoResultsViewHolder();
noResultsHolder.titleView = view.findViewById(ID_PREFERENCE_TITLE);
noResultsHolder.summaryView = view.findViewById(ID_PREFERENCE_SUMMARY);
noResultsHolder.iconView = view.findViewById(android.R.id.icon);
view.setTag(noResultsHolder);
}
default -> throw new IllegalStateException("Unknown viewType: " + viewType);
}
}
protected void bindDataToViewHolder(BaseSearchResultItem item, Object holder,
BaseSearchResultItem.ViewType viewType, View view) {
switch (viewType) {
case REGULAR, URL_LINK, LIST -> bindRegularViewHolder(item, (RegularViewHolder) holder, view);
case SWITCH -> bindSwitchViewHolder(item, (SwitchViewHolder) holder, view);
case COLOR_PICKER -> bindColorViewHolder(item, (ColorViewHolder) holder, view);
case GROUP_HEADER -> bindGroupHeaderViewHolder(item, (GroupHeaderViewHolder) holder, view);
case NO_RESULTS -> bindNoResultsViewHolder(item, (NoResultsViewHolder) holder);
default -> throw new IllegalStateException("Unknown viewType: " + viewType);
}
}
protected void bindRegularViewHolder(BaseSearchResultItem item, RegularViewHolder holder, View view) {
BaseSearchResultItem.PreferenceSearchItem prefItem = (BaseSearchResultItem.PreferenceSearchItem) item;
prefItem.refreshHighlighting();
holder.titleView.setText(item.highlightedTitle);
holder.summaryView.setText(item.highlightedSummary);
holder.summaryView.setVisibility(TextUtils.isEmpty(item.highlightedSummary) ? View.GONE : View.VISIBLE);
setupPreferenceView(view, holder.titleView, holder.summaryView, prefItem.preference,
() -> {
handlePreferenceClick(prefItem.preference);
if (prefItem.preference instanceof ListPreference) {
prefItem.refreshHighlighting();
holder.summaryView.setText(prefItem.getCurrentEffectiveSummary());
holder.summaryView.setVisibility(TextUtils.isEmpty(prefItem.highlightedSummary) ? View.GONE : View.VISIBLE);
notifyDataSetChanged();
}
},
() -> navigateAndScrollToPreference(item));
}
protected void bindSwitchViewHolder(BaseSearchResultItem item, SwitchViewHolder holder, View view) {
BaseSearchResultItem.PreferenceSearchItem prefItem = (BaseSearchResultItem.PreferenceSearchItem) item;
SwitchPreference switchPref = (SwitchPreference) prefItem.preference;
holder.titleView.setText(item.highlightedTitle);
holder.switchWidget.setBackground(null); // Remove ripple/highlight.
// Sync switch state with preference without animation.
boolean currentState = switchPref.isChecked();
if (holder.switchWidget.isChecked() != currentState) {
holder.switchWidget.setChecked(currentState);
holder.switchWidget.jumpDrawablesToCurrentState();
}
prefItem.refreshHighlighting();
holder.summaryView.setText(prefItem.highlightedSummary);
holder.summaryView.setVisibility(TextUtils.isEmpty(prefItem.highlightedSummary) ? View.GONE : View.VISIBLE);
setupPreferenceView(view, holder.titleView, holder.summaryView, switchPref,
() -> {
boolean newState = !switchPref.isChecked();
switchPref.setChecked(newState);
holder.switchWidget.setChecked(newState);
prefItem.refreshHighlighting();
holder.summaryView.setText(prefItem.getCurrentEffectiveSummary());
holder.summaryView.setVisibility(TextUtils.isEmpty(prefItem.highlightedSummary) ? View.GONE : View.VISIBLE);
if (switchPref.getOnPreferenceChangeListener() != null) {
switchPref.getOnPreferenceChangeListener().onPreferenceChange(switchPref, newState);
}
notifyDataSetChanged();
},
() -> navigateAndScrollToPreference(item));
holder.switchWidget.setEnabled(switchPref.isEnabled());
}
protected void bindColorViewHolder(BaseSearchResultItem item, ColorViewHolder holder, View view) {
BaseSearchResultItem.PreferenceSearchItem prefItem = (BaseSearchResultItem.PreferenceSearchItem) item;
holder.titleView.setText(item.highlightedTitle);
holder.summaryView.setText(item.highlightedSummary);
holder.summaryView.setVisibility(TextUtils.isEmpty(item.highlightedSummary) ? View.GONE : View.VISIBLE);
ColorDot.applyColorDot(holder.colorDot, prefItem.getColor(), prefItem.preference.isEnabled());
setupPreferenceView(view, holder.titleView, holder.summaryView, prefItem.preference,
() -> handlePreferenceClick(prefItem.preference),
() -> navigateAndScrollToPreference(item));
}
protected void bindGroupHeaderViewHolder(BaseSearchResultItem item, GroupHeaderViewHolder holder, View view) {
holder.pathView.setText(item.highlightedTitle);
view.setOnClickListener(v -> navigateToTargetScreen(item));
}
protected void bindNoResultsViewHolder(BaseSearchResultItem item, NoResultsViewHolder holder) {
holder.titleView.setText(item.highlightedTitle);
holder.summaryView.setText(item.highlightedSummary);
holder.summaryView.setVisibility(TextUtils.isEmpty(item.highlightedSummary) ? View.GONE : View.VISIBLE);
holder.iconView.setImageResource(DRAWABLE_REVANCED_SETTINGS_SEARCH_ICON);
}
/**
* Sets up a preference view with click listeners and proper enabled state handling.
*/
protected void setupPreferenceView(View view, TextView titleView, TextView summaryView, Preference preference,
Runnable onClickAction, Runnable onLongClickAction) {
boolean enabled = preference.isEnabled();
// To enable long-click navigation for disabled settings, manually control the enabled state of the title
// and summary and disable the ripple effect instead of using 'view.setEnabled(enabled)'.
titleView.setEnabled(enabled);
summaryView.setEnabled(enabled);
if (!enabled) view.setBackground(null); // Disable ripple effect.
// In light mode, alpha 0.5 is applied to a disabled title automatically,
// but in dark mode it needs to be applied manually.
if (Utils.isDarkModeEnabled()) {
titleView.setAlpha(enabled ? 1.0f : ColorPickerPreference.DISABLED_ALPHA);
}
// Set up click and long-click listeners.
view.setOnClickListener(enabled ? v -> onClickAction.run() : null);
view.setOnLongClickListener(v -> {
onLongClickAction.run();
return true;
});
}
/**
* Navigates to the settings screen containing the given search result item and triggers scrolling.
*/
protected void navigateAndScrollToPreference(BaseSearchResultItem item) {
// No navigation for URL_LINK items.
if (item.preferenceType == BaseSearchResultItem.ViewType.URL_LINK) return;
PreferenceScreen targetScreen = navigateToTargetScreen(item);
if (targetScreen == null) return;
if (!(item instanceof BaseSearchResultItem.PreferenceSearchItem prefItem)) return;
Preference targetPreference = prefItem.preference;
fragment.getView().post(() -> {
ListView listView = targetScreen == getMainPreferenceScreen()
? getPreferenceListView()
: targetScreen.getDialog().findViewById(android.R.id.list);
if (listView == null) return;
int targetPosition = findPreferencePosition(targetPreference, listView);
if (targetPosition == -1) return;
int firstVisible = listView.getFirstVisiblePosition();
int lastVisible = listView.getLastVisiblePosition();
if (targetPosition >= firstVisible && targetPosition <= lastVisible) {
// The preference is already visible, but still scroll it to the bottom of the list for consistency.
View child = listView.getChildAt(targetPosition - firstVisible);
if (child != null) {
// Calculate how much to scroll so the item is aligned at the bottom.
int scrollAmount = child.getBottom() - listView.getHeight();
if (scrollAmount > 0) {
// Perform smooth scroll animation for better user experience.
listView.smoothScrollBy(scrollAmount, 300);
}
}
// Highlight the preference once it is positioned.
highlightPreferenceAtPosition(listView, targetPosition);
} else {
// The preference is outside of the current visible range, scroll to it from the top.
listView.smoothScrollToPositionFromTop(targetPosition, 0);
Handler handler = new Handler(Looper.getMainLooper());
// Fallback runnable in case the OnScrollListener does not trigger.
Runnable fallback = () -> {
listView.setOnScrollListener(null);
highlightPreferenceAtPosition(listView, targetPosition);
};
// Post fallback with a small delay.
handler.postDelayed(fallback, 350);
listView.setOnScrollListener(new AbsListView.OnScrollListener() {
private boolean isScrolling = false;
@Override
public void onScrollStateChanged(AbsListView view, int scrollState) {
if (scrollState == SCROLL_STATE_TOUCH_SCROLL || scrollState == SCROLL_STATE_FLING) {
// Mark that scrolling has started.
isScrolling = true;
}
if (scrollState == SCROLL_STATE_IDLE && isScrolling) {
// Scrolling is finished, cleanup listener and cancel fallback.
isScrolling = false;
listView.setOnScrollListener(null);
handler.removeCallbacks(fallback);
// Highlight the target preference when scrolling is done.
highlightPreferenceAtPosition(listView, targetPosition);
}
}
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {}
});
}
});
}
/**
* Navigates to the final PreferenceScreen using preference keys or titles as fallback.
*/
protected PreferenceScreen navigateToTargetScreen(BaseSearchResultItem item) {
PreferenceScreen currentScreen = getMainPreferenceScreen();
Preference targetPref = null;
// Try key-based navigation first.
if (item.navigationKeys != null && !item.navigationKeys.isEmpty()) {
String finalKey = item.navigationKeys.get(item.navigationKeys.size() - 1);
targetPref = findPreferenceByKey(currentScreen, finalKey);
}
// Fallback to title-based navigation.
if (targetPref == null && !TextUtils.isEmpty(item.navigationPath)) {
String[] pathSegments = item.navigationPath.split(" > ");
String finalSegment = pathSegments[pathSegments.length - 1].trim();
if (!TextUtils.isEmpty(finalSegment)) {
targetPref = findPreferenceByTitle(currentScreen, finalSegment);
}
}
if (targetPref instanceof PreferenceScreen targetScreen) {
handlePreferenceClick(targetScreen);
return targetScreen;
}
return currentScreen;
}
/**
* Recursively searches for a preference by title in a preference group.
*/
protected Preference findPreferenceByTitle(PreferenceGroup group, String title) {
for (int i = 0; i < group.getPreferenceCount(); i++) {
Preference pref = group.getPreference(i);
CharSequence prefTitle = pref.getTitle();
if (prefTitle != null && (prefTitle.toString().trim().equalsIgnoreCase(title)
|| normalizeString(prefTitle.toString()).equals(normalizeString(title)))) {
return pref;
}
if (pref instanceof PreferenceGroup) {
Preference found = findPreferenceByTitle((PreferenceGroup) pref, title);
if (found != null) {
return found;
}
}
}
return null;
}
/**
* Normalizes string for comparison (removes extra characters, spaces etc).
*/
protected String normalizeString(String input) {
if (TextUtils.isEmpty(input)) return "";
return input.trim().toLowerCase().replaceAll("\\s+", " ").replaceAll("[^\\w\\s]", "");
}
/**
* Gets the ListView from the PreferenceFragment.
*/
protected ListView getPreferenceListView() {
View fragmentView = fragment.getView();
if (fragmentView != null) {
ListView listView = findListViewInViewGroup(fragmentView);
if (listView != null) {
return listView;
}
}
return fragment.getActivity().findViewById(android.R.id.list);
}
/**
* Recursively searches for a ListView in a ViewGroup.
*/
protected ListView findListViewInViewGroup(View view) {
if (view instanceof ListView) {
return (ListView) view;
}
if (view instanceof ViewGroup group) {
for (int i = 0; i < group.getChildCount(); i++) {
ListView result = findListViewInViewGroup(group.getChildAt(i));
if (result != null) {
return result;
}
}
}
return null;
}
/**
* Finds the position of a preference in the ListView adapter.
*/
protected int findPreferencePosition(Preference targetPreference, ListView listView) {
ListAdapter adapter = listView.getAdapter();
if (adapter == null) {
return -1;
}
for (int i = 0; i < adapter.getCount(); i++) {
Object item = adapter.getItem(i);
if (item == targetPreference) {
return i;
}
if (item instanceof Preference pref && targetPreference.getKey() != null) {
if (targetPreference.getKey().equals(pref.getKey())) {
return i;
}
}
}
return -1;
}
/**
* Highlights a preference at the specified position with a blink effect.
*/
protected void highlightPreferenceAtPosition(ListView listView, int position) {
int firstVisible = listView.getFirstVisiblePosition();
if (position < firstVisible || position > listView.getLastVisiblePosition()) {
return;
}
View itemView = listView.getChildAt(position - firstVisible);
if (itemView != null) {
blinkView(itemView);
}
}
/**
* Creates a smooth double-blink effect on a view's background without affecting the text.
* @param view The View to apply the animation to.
*/
protected void blinkView(View view) {
// If a previous animation is still running, cancel it to prevent conflicts.
if (currentAnimator != null && currentAnimator.isRunning()) {
currentAnimator.cancel();
}
int startColor = Utils.getAppBackgroundColor();
int highlightColor = Utils.adjustColorBrightness(
startColor,
Utils.isDarkModeEnabled() ? 1.25f : 0.8f
);
// Animator for transitioning from the start color to the highlight color.
ObjectAnimator fadeIn = ObjectAnimator.ofObject(
view,
"backgroundColor",
new ArgbEvaluator(),
startColor,
highlightColor
);
fadeIn.setDuration(BLINK_DURATION);
// Animator to return to the start color.
ObjectAnimator fadeOut = ObjectAnimator.ofObject(
view,
"backgroundColor",
new ArgbEvaluator(),
highlightColor,
startColor
);
fadeOut.setDuration(BLINK_DURATION);
currentAnimator = new AnimatorSet();
// Create the sequence: fadeIn -> fadeOut -> (pause) -> fadeIn -> fadeOut.
AnimatorSet firstBlink = new AnimatorSet();
firstBlink.playSequentially(fadeIn, fadeOut);
AnimatorSet secondBlink = new AnimatorSet();
secondBlink.playSequentially(fadeIn.clone(), fadeOut.clone()); // Use clones for the second blink.
currentAnimator.play(secondBlink).after(firstBlink).after(PAUSE_BETWEEN_BLINKS);
currentAnimator.start();
}
/**
* Recursively finds a preference by key in a preference group.
*/
protected Preference findPreferenceByKey(PreferenceGroup group, String key) {
if (group == null || TextUtils.isEmpty(key)) {
return null;
}
// First search on current level.
for (int i = 0; i < group.getPreferenceCount(); i++) {
Preference pref = group.getPreference(i);
if (key.equals(pref.getKey())) {
return pref;
}
if (pref instanceof PreferenceGroup) {
Preference found = findPreferenceByKey((PreferenceGroup) pref, key);
if (found != null) {
return found;
}
}
}
return null;
}
/**
* Handles preference click actions by invoking the preference's performClick method via reflection.
*/
@SuppressWarnings("all")
private void handlePreferenceClick(Preference preference) {
try {
if (preference instanceof CustomDialogListPreference listPref) {
BaseSearchResultItem.PreferenceSearchItem searchItem =
searchViewController.findSearchItemByPreference(preference);
if (searchItem != null && searchItem.isEntriesHighlightingApplied()) {
listPref.setHighlightedEntriesForDialog(searchItem.getHighlightedEntries());
}
}
Method m = Preference.class.getDeclaredMethod("performClick", PreferenceScreen.class);
m.setAccessible(true);
m.invoke(preference, fragment.getPreferenceScreenForSearch());
} catch (Exception e) {
Logger.printException(() -> "Failed to invoke performClick()", e);
}
}
/**
* Checks if a preference has navigation capability (can open a new screen).
*/
boolean hasNavigationCapability(Preference preference) {
// PreferenceScreen always allows navigation.
if (preference instanceof PreferenceScreen) return true;
// UrlLinkPreference does not navigate to a new screen, it opens an external URL.
if (preference instanceof UrlLinkPreference) return false;
// Other group types that might have their own screens.
if (preference instanceof PreferenceGroup) {
// Check if it has its own fragment or intent.
return preference.getIntent() != null || preference.getFragment() != null;
}
return false;
}
}

View File

@@ -0,0 +1,653 @@
package app.revanced.extension.shared.settings.search;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
import android.app.Activity;
import android.content.Context;
import android.graphics.drawable.GradientDrawable;
import android.preference.Preference;
import android.preference.PreferenceCategory;
import android.preference.PreferenceGroup;
import android.preference.PreferenceScreen;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.View;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ListView;
import android.widget.SearchView;
import android.widget.Toolbar;
import androidx.annotation.ColorInt;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.AppLanguage;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.shared.settings.preference.ColorPickerPreference;
import app.revanced.extension.shared.settings.preference.CustomDialogListPreference;
import app.revanced.extension.shared.settings.preference.NoTitlePreferenceCategory;
/**
* Abstract controller for managing the overlay search view in ReVanced settings.
* Subclasses must implement app-specific preference handling.
*/
@SuppressWarnings("deprecation")
public abstract class BaseSearchViewController {
protected SearchView searchView;
protected FrameLayout searchContainer;
protected FrameLayout overlayContainer;
protected final Toolbar toolbar;
protected final Activity activity;
protected final BasePreferenceFragment fragment;
protected final CharSequence originalTitle;
protected BaseSearchResultsAdapter searchResultsAdapter;
protected final List<BaseSearchResultItem> allSearchItems;
protected final List<BaseSearchResultItem> filteredSearchItems;
protected final Map<String, BaseSearchResultItem> keyToSearchItem;
protected final InputMethodManager inputMethodManager;
protected SearchHistoryManager searchHistoryManager;
protected boolean isSearchActive;
protected boolean isShowingSearchHistory;
protected static final int MAX_SEARCH_RESULTS = 50; // Maximum number of search results displayed.
protected static final int ID_REVANCED_SEARCH_VIEW = getResourceIdentifierOrThrow("revanced_search_view", "id");
protected static final int ID_REVANCED_SEARCH_VIEW_CONTAINER = getResourceIdentifierOrThrow("revanced_search_view_container", "id");
protected static final int ID_ACTION_SEARCH = getResourceIdentifierOrThrow("action_search", "id");
protected static final int ID_REVANCED_SETTINGS_FRAGMENTS = getResourceIdentifierOrThrow("revanced_settings_fragments", "id");
public static final int DRAWABLE_REVANCED_SETTINGS_SEARCH_ICON =
getResourceIdentifierOrThrow("revanced_settings_search_icon", "drawable");
protected static final int MENU_REVANCED_SEARCH_MENU =
getResourceIdentifierOrThrow("revanced_search_menu", "menu");
/**
* Constructs a new BaseSearchViewController instance.
*
* @param activity The activity hosting the search view.
* @param toolbar The toolbar containing the search action.
* @param fragment The preference fragment to manage search preferences.
*/
protected BaseSearchViewController(Activity activity, Toolbar toolbar, BasePreferenceFragment fragment) {
this.activity = activity;
this.toolbar = toolbar;
this.fragment = fragment;
this.originalTitle = toolbar.getTitle();
this.allSearchItems = new ArrayList<>();
this.filteredSearchItems = new ArrayList<>();
this.keyToSearchItem = new HashMap<>();
this.inputMethodManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
this.isShowingSearchHistory = false;
// Initialize components
initializeSearchView();
initializeOverlayContainer();
initializeSearchHistoryManager();
setupToolbarMenu();
setupListeners();
}
/**
* Initializes the search view with proper configurations, such as background, query hint, and RTL support.
*/
private void initializeSearchView() {
// Retrieve SearchView and container from XML.
searchView = activity.findViewById(ID_REVANCED_SEARCH_VIEW);
EditText searchEditText = searchView.findViewById(Utils.getResourceIdentifierOrThrow(
"android:id/search_src_text", null));
// Disable fullscreen keyboard mode.
searchEditText.setImeOptions(searchEditText.getImeOptions() | EditorInfo.IME_FLAG_NO_EXTRACT_UI);
searchContainer = activity.findViewById(ID_REVANCED_SEARCH_VIEW_CONTAINER);
// Set background and query hint.
searchView.setBackground(createBackgroundDrawable());
searchView.setQueryHint(str("revanced_settings_search_hint"));
// Configure RTL support based on app language.
AppLanguage appLanguage = BaseSettings.REVANCED_LANGUAGE.get();
if (Utils.isRightToLeftLocale(appLanguage.getLocale())) {
searchView.setTextDirection(View.TEXT_DIRECTION_RTL);
searchView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_END);
}
}
/**
* Initializes the overlay container for displaying search results and history.
*/
private void initializeOverlayContainer() {
// Create overlay container for search results and history.
overlayContainer = new FrameLayout(activity);
overlayContainer.setVisibility(View.GONE);
overlayContainer.setBackgroundColor(Utils.getAppBackgroundColor());
overlayContainer.setElevation(Utils.dipToPixels(8));
// Container for search results.
FrameLayout searchResultsContainer = new FrameLayout(activity);
searchResultsContainer.setVisibility(View.VISIBLE);
// Create a ListView for the results.
ListView searchResultsListView = new ListView(activity);
searchResultsListView.setDivider(null);
searchResultsListView.setDividerHeight(0);
searchResultsAdapter = createSearchResultsAdapter();
searchResultsListView.setAdapter(searchResultsAdapter);
// Add results list into container.
searchResultsContainer.addView(searchResultsListView, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT));
// Add results container into overlay.
overlayContainer.addView(searchResultsContainer, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT));
// Add overlay to the main content container.
FrameLayout mainContainer = activity.findViewById(ID_REVANCED_SETTINGS_FRAGMENTS);
if (mainContainer != null) {
FrameLayout.LayoutParams overlayParams = new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT);
overlayParams.gravity = Gravity.TOP;
mainContainer.addView(overlayContainer, overlayParams);
}
}
/**
* Initializes the search history manager with the specified overlay container and listener.
*/
private void initializeSearchHistoryManager() {
searchHistoryManager = new SearchHistoryManager(activity, overlayContainer, query -> {
searchView.setQuery(query, true);
hideSearchHistory();
});
}
// Abstract methods that subclasses must implement.
protected abstract BaseSearchResultsAdapter createSearchResultsAdapter();
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
protected abstract boolean isSpecialPreferenceGroup(Preference preference);
protected abstract void setupSpecialPreferenceListeners(BaseSearchResultItem item);
// Abstract interface for preference fragments.
public interface BasePreferenceFragment {
PreferenceScreen getPreferenceScreenForSearch();
android.view.View getView();
Activity getActivity();
}
/**
* Determines whether a preference should be included in the search index.
*
* @param preference The preference to evaluate.
* @param currentDepth The current depth in the preference hierarchy.
* @param includeDepth The maximum depth to include in the search index.
* @return True if the preference should be included, false otherwise.
*/
protected boolean shouldIncludePreference(Preference preference, int currentDepth, int includeDepth) {
return includeDepth <= currentDepth
&& !(preference instanceof PreferenceCategory)
&& !isSpecialPreferenceGroup(preference)
&& !(preference instanceof PreferenceScreen);
}
/**
* Sets up the toolbar menu for the search action.
*/
protected void setupToolbarMenu() {
toolbar.inflateMenu(MENU_REVANCED_SEARCH_MENU);
toolbar.setOnMenuItemClickListener(item -> {
if (item.getItemId() == ID_ACTION_SEARCH && !isSearchActive) {
openSearch();
return true;
}
return false;
});
}
/**
* Configures listeners for the search view and toolbar navigation.
*/
protected void setupListeners() {
searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
@Override
public boolean onQueryTextSubmit(String query) {
try {
String queryTrimmed = query.trim();
if (!queryTrimmed.isEmpty()) {
searchHistoryManager.saveSearchQuery(queryTrimmed);
}
} catch (Exception ex) {
Logger.printException(() -> "onQueryTextSubmit failure", ex);
}
return false;
}
@Override
public boolean onQueryTextChange(String newText) {
try {
Logger.printDebug(() -> "Search query: " + newText);
String trimmedText = newText.trim();
if (!isSearchActive) {
Logger.printDebug(() -> "Search is not active, skipping query processing");
return true;
}
if (trimmedText.isEmpty()) {
// If empty query: show history.
hideSearchResults();
showSearchHistory();
} else {
// If has search text: hide history and show search results.
hideSearchHistory();
filterAndShowResults(newText);
}
} catch (Exception ex) {
Logger.printException(() -> "onQueryTextChange failure", ex);
}
return true;
}
});
// Set navigation click listener.
toolbar.setNavigationOnClickListener(view -> {
if (isSearchActive) {
closeSearch();
} else {
activity.finish();
}
});
}
/**
* Initializes search data by collecting all searchable preferences from the fragment.
* This method should be called after the preference fragment is fully loaded.
* Runs on the UI thread to ensure proper access to preference components.
*/
public void initializeSearchData() {
allSearchItems.clear();
keyToSearchItem.clear();
// Wait until fragment is properly initialized.
activity.runOnUiThread(() -> {
try {
PreferenceScreen screen = fragment.getPreferenceScreenForSearch();
if (screen != null) {
collectSearchablePreferences(screen);
for (BaseSearchResultItem item : allSearchItems) {
if (item instanceof BaseSearchResultItem.PreferenceSearchItem prefItem) {
String key = prefItem.preference.getKey();
if (key != null) {
keyToSearchItem.put(key, item);
}
}
}
setupPreferenceListeners();
Logger.printDebug(() -> "Collected " + allSearchItems.size() + " searchable preferences");
}
} catch (Exception ex) {
Logger.printException(() -> "Failed to initialize search data", ex);
}
});
}
/**
* Sets up listeners for preferences to keep search results in sync when preference values change.
*/
protected void setupPreferenceListeners() {
for (BaseSearchResultItem item : allSearchItems) {
// Skip non-preference items.
if (!(item instanceof BaseSearchResultItem.PreferenceSearchItem prefItem)) continue;
Preference pref = prefItem.preference;
if (pref instanceof ColorPickerPreference colorPref) {
colorPref.setOnColorChangeListener((prefKey, newColor) -> {
BaseSearchResultItem.PreferenceSearchItem searchItem =
(BaseSearchResultItem.PreferenceSearchItem) keyToSearchItem.get(prefKey);
if (searchItem != null) {
searchItem.setColor(newColor);
refreshSearchResults();
}
});
} else if (pref instanceof CustomDialogListPreference listPref) {
listPref.setOnPreferenceChangeListener((preference, newValue) -> {
BaseSearchResultItem.PreferenceSearchItem searchItem =
(BaseSearchResultItem.PreferenceSearchItem) keyToSearchItem.get(preference.getKey());
if (searchItem == null) return true;
int index = listPref.findIndexOfValue(newValue.toString());
if (index >= 0) {
// Check if a static summary is set.
boolean isStaticSummary = listPref.getStaticSummary() != null;
if (!isStaticSummary) {
// Only update summary if it is not static.
CharSequence newSummary = listPref.getEntries()[index];
listPref.setSummary(newSummary);
}
}
listPref.clearHighlightedEntriesForDialog();
searchItem.refreshHighlighting();
refreshSearchResults();
return true;
});
}
// Let subclasses handle special preferences.
setupSpecialPreferenceListeners(item);
}
}
/**
* Collects searchable preferences from a preference group.
*/
protected void collectSearchablePreferences(PreferenceGroup group) {
collectSearchablePreferencesWithKeys(group, "", new ArrayList<>(), 1, 0);
}
/**
* Collects searchable preferences with their navigation paths and keys.
*
* @param group The preference group to collect from.
* @param parentPath The navigation path of the parent group.
* @param parentKeys The keys of parent preferences.
* @param includeDepth The maximum depth to include in the search index.
* @param currentDepth The current depth in the preference hierarchy.
*/
protected void collectSearchablePreferencesWithKeys(PreferenceGroup group, String parentPath,
List<String> parentKeys, int includeDepth, int currentDepth) {
if (group == null) return;
for (int i = 0, count = group.getPreferenceCount(); i < count; i++) {
Preference preference = group.getPreference(i);
// Add to search results only if it is not a category, special group, or PreferenceScreen.
if (shouldIncludePreference(preference, currentDepth, includeDepth)) {
allSearchItems.add(new BaseSearchResultItem.PreferenceSearchItem(
preference, parentPath, parentKeys));
}
// If the preference is a group, recurse into it.
if (preference instanceof PreferenceGroup subGroup) {
String newPath = parentPath;
List<String> newKeys = new ArrayList<>(parentKeys);
// Append the group title to the path and save key for navigation.
if (!isSpecialPreferenceGroup(preference)
&& !(preference instanceof NoTitlePreferenceCategory)) {
CharSequence title = preference.getTitle();
if (!TextUtils.isEmpty(title)) {
newPath = TextUtils.isEmpty(parentPath)
? title.toString()
: parentPath + " > " + title;
}
// Add key for navigation if this is a PreferenceScreen or group with navigation capability.
String key = preference.getKey();
if (!TextUtils.isEmpty(key) && (preference instanceof PreferenceScreen
|| searchResultsAdapter.hasNavigationCapability(preference))) {
newKeys.add(key);
}
}
collectSearchablePreferencesWithKeys(subGroup, newPath, newKeys, includeDepth, currentDepth + 1);
}
}
}
/**
* Filters all search items based on the provided query and displays results in the overlay.
* Applies highlighting to matching text and shows a "no results" message if nothing matches.
*/
protected void filterAndShowResults(String query) {
hideSearchHistory();
// Keep track of the previously displayed items to clear their highlights.
List<BaseSearchResultItem> previouslyDisplayedItems = new ArrayList<>(filteredSearchItems);
filteredSearchItems.clear();
String queryLower = Utils.removePunctuationToLowercase(query);
Pattern queryPattern = Pattern.compile(Pattern.quote(queryLower), Pattern.CASE_INSENSITIVE);
// Clear highlighting only for items that were previously visible.
// This avoids iterating through all items on every keystroke during filtering.
for (BaseSearchResultItem item : previouslyDisplayedItems) {
item.clearHighlighting();
}
// Collect matched items first.
List<BaseSearchResultItem> matched = new ArrayList<>();
int matchCount = 0;
for (BaseSearchResultItem item : allSearchItems) {
if (matchCount >= MAX_SEARCH_RESULTS) break; // Stop after collecting max results.
if (item.matchesQuery(queryLower)) {
item.applyHighlighting(queryPattern);
matched.add(item);
matchCount++;
}
}
// Build filteredSearchItems, inserting parent enablers for disabled dependents.
Set<String> addedParentKeys = new HashSet<>(2 * matched.size());
for (BaseSearchResultItem item : matched) {
if (item instanceof BaseSearchResultItem.PreferenceSearchItem prefItem) {
String key = prefItem.preference.getKey();
Setting<?> setting = (key != null) ? Setting.getSettingFromPath(key) : null;
if (setting != null && !setting.isAvailable()) {
List<Setting<?>> parentSettings = setting.getParentSettings();
for (Setting<?> parentSetting : parentSettings) {
BaseSearchResultItem parentItem = keyToSearchItem.get(parentSetting.key);
if (parentItem != null && !addedParentKeys.contains(parentSetting.key)) {
if (!parentItem.matchesQuery(queryLower)) {
// Apply highlighting to parent items even if they don't match the query.
// This ensures they get their current effective summary calculated.
parentItem.applyHighlighting(queryPattern);
filteredSearchItems.add(parentItem);
}
addedParentKeys.add(parentSetting.key);
}
}
}
filteredSearchItems.add(item);
if (key != null) {
addedParentKeys.add(key);
}
}
}
if (!filteredSearchItems.isEmpty()) {
//noinspection ComparatorCombinators
Collections.sort(filteredSearchItems, (o1, o2) ->
o1.navigationPath.compareTo(o2.navigationPath)
);
List<BaseSearchResultItem> displayItems = new ArrayList<>();
String currentPath = null;
for (BaseSearchResultItem item : filteredSearchItems) {
if (!item.navigationPath.equals(currentPath)) {
BaseSearchResultItem header = new BaseSearchResultItem.GroupHeaderItem(item.navigationPath, item.navigationKeys);
displayItems.add(header);
currentPath = item.navigationPath;
}
displayItems.add(item);
}
filteredSearchItems.clear();
filteredSearchItems.addAll(displayItems);
}
// Show "No results found" if search results are empty.
if (filteredSearchItems.isEmpty()) {
Preference noResultsPreference = new Preference(activity);
noResultsPreference.setKey("no_results_placeholder");
noResultsPreference.setTitle(str("revanced_settings_search_no_results_title", query));
noResultsPreference.setSummary(str("revanced_settings_search_no_results_summary"));
noResultsPreference.setSelectable(false);
noResultsPreference.setIcon(DRAWABLE_REVANCED_SETTINGS_SEARCH_ICON);
filteredSearchItems.add(new BaseSearchResultItem.PreferenceSearchItem(noResultsPreference, "", Collections.emptyList()));
}
searchResultsAdapter.notifyDataSetChanged();
overlayContainer.setVisibility(View.VISIBLE);
}
/**
* Opens the search interface by showing the search view and hiding the menu item.
* Configures the UI for search mode, shows the keyboard, and displays search suggestions.
*/
protected void openSearch() {
isSearchActive = true;
toolbar.getMenu().findItem(ID_ACTION_SEARCH).setVisible(false);
toolbar.setTitle("");
searchContainer.setVisibility(View.VISIBLE);
searchView.requestFocus();
// Configure soft input mode to adjust layout and show keyboard.
activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN
| WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);
inputMethodManager.showSoftInput(searchView, InputMethodManager.SHOW_IMPLICIT);
// Always show search history when opening search.
showSearchHistory();
}
/**
* Closes the search interface and restores the normal UI state.
* Hides the overlay, clears search results, dismisses the keyboard, and removes highlighting.
*/
public void closeSearch() {
isSearchActive = false;
isShowingSearchHistory = false;
searchHistoryManager.hideSearchHistoryContainer();
overlayContainer.setVisibility(View.GONE);
filteredSearchItems.clear();
searchContainer.setVisibility(View.GONE);
toolbar.getMenu().findItem(ID_ACTION_SEARCH).setVisible(true);
toolbar.setTitle(originalTitle);
searchView.setQuery("", false);
// Hide keyboard and reset soft input mode.
inputMethodManager.hideSoftInputFromWindow(searchView.getWindowToken(), 0);
activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN);
// Clear highlighting for all search items.
for (BaseSearchResultItem item : allSearchItems) {
item.clearHighlighting();
}
searchResultsAdapter.notifyDataSetChanged();
}
/**
* Shows the search history if enabled.
*/
protected void showSearchHistory() {
if (searchHistoryManager.isSearchHistoryEnabled()) {
overlayContainer.setVisibility(View.VISIBLE);
searchHistoryManager.showSearchHistory();
isShowingSearchHistory = true;
} else {
hideAllOverlays();
}
}
/**
* Hides the search history container.
*/
protected void hideSearchHistory() {
searchHistoryManager.hideSearchHistoryContainer();
isShowingSearchHistory = false;
}
/**
* Hides all overlay containers, including search results and history.
*/
protected void hideAllOverlays() {
hideSearchHistory();
hideSearchResults();
}
/**
* Hides the search results overlay and clears the filtered results.
*/
protected void hideSearchResults() {
overlayContainer.setVisibility(View.GONE);
filteredSearchItems.clear();
searchResultsAdapter.notifyDataSetChanged();
for (BaseSearchResultItem item : allSearchItems) {
item.clearHighlighting();
}
}
/**
* Refreshes the search results display if the search is active and history is not shown.
*/
protected void refreshSearchResults() {
if (isSearchActive && !isShowingSearchHistory) {
searchResultsAdapter.notifyDataSetChanged();
}
}
/**
* Finds a search item corresponding to the given preference.
*
* @param preference The preference to find a search item for.
* @return The corresponding PreferenceSearchItem, or null if not found.
*/
public BaseSearchResultItem.PreferenceSearchItem findSearchItemByPreference(Preference preference) {
// First, search in filtered results.
for (BaseSearchResultItem item : filteredSearchItems) {
if (item instanceof BaseSearchResultItem.PreferenceSearchItem prefItem) {
if (prefItem.preference == preference) {
return prefItem;
}
}
}
// If not found, search in all items.
for (BaseSearchResultItem item : allSearchItems) {
if (item instanceof BaseSearchResultItem.PreferenceSearchItem prefItem) {
if (prefItem.preference == preference) {
return prefItem;
}
}
}
return null;
}
/**
* Gets the background color for search view components based on current theme.
*/
@ColorInt
public static int getSearchViewBackground() {
return Utils.adjustColorBrightness(Utils.getDialogBackgroundColor(), Utils.isDarkModeEnabled() ? 1.11f : 0.95f);
}
/**
* Creates a rounded background drawable for the main search view.
*/
protected static GradientDrawable createBackgroundDrawable() {
GradientDrawable background = new GradientDrawable();
background.setShape(GradientDrawable.RECTANGLE);
background.setCornerRadius(Utils.dipToPixels(28));
background.setColor(getSearchViewBackground());
return background;
}
/**
* Return if a search is currently active.
*/
public boolean isSearchActive() {
return isSearchActive;
}
}

View File

@@ -0,0 +1,377 @@
package app.revanced.extension.shared.settings.search;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
import static app.revanced.extension.shared.settings.BaseSettings.SETTINGS_SEARCH_ENTRIES;
import static app.revanced.extension.shared.settings.BaseSettings.SETTINGS_SEARCH_HISTORY;
import android.app.Activity;
import android.app.Dialog;
import android.content.Context;
import android.util.Pair;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Deque;
import java.util.LinkedList;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.settings.preference.BulletPointPreference;
import app.revanced.extension.shared.ui.CustomDialog;
/**
* Manager for search history functionality.
*/
public class SearchHistoryManager {
/**
* Interface for handling history item selection.
*/
private static final int MAX_HISTORY_SIZE = 5; // Maximum history items stored.
private static final int ID_CLEAR_HISTORY_BUTTON = getResourceIdentifierOrThrow(
"clear_history_button", "id");
private static final int ID_HISTORY_TEXT = getResourceIdentifierOrThrow(
"history_text", "id");
private static final int ID_DELETE_ICON = getResourceIdentifierOrThrow(
"delete_icon", "id");
private static final int ID_EMPTY_HISTORY_TITLE = getResourceIdentifierOrThrow(
"empty_history_title", "id");
private static final int ID_EMPTY_HISTORY_SUMMARY = getResourceIdentifierOrThrow(
"empty_history_summary", "id");
private static final int ID_SEARCH_HISTORY_HEADER = getResourceIdentifierOrThrow(
"search_history_header", "id");
private static final int ID_SEARCH_TIPS_SUMMARY = getResourceIdentifierOrThrow(
"revanced_settings_search_tips_summary", "id");
private static final int LAYOUT_REVANCED_PREFERENCE_SEARCH_HISTORY_SCREEN = getResourceIdentifierOrThrow(
"revanced_preference_search_history_screen", "layout");
private static final int LAYOUT_REVANCED_PREFERENCE_SEARCH_HISTORY_ITEM = getResourceIdentifierOrThrow(
"revanced_preference_search_history_item", "layout");
private static final int ID_SEARCH_HISTORY_LIST = getResourceIdentifierOrThrow(
"search_history_list", "id");
private final Deque<String> searchHistory;
private final Activity activity;
private final SearchHistoryAdapter searchHistoryAdapter;
private final boolean showSettingsSearchHistory;
private final FrameLayout searchHistoryContainer;
public interface OnSelectHistoryItemListener {
void onSelectHistoryItem(String query);
}
/**
* Constructor for SearchHistoryManager.
*
* @param activity The parent activity.
* @param overlayContainer The overlay container to hold the search history container.
* @param onSelectHistoryItemAction Callback for when a history item is selected.
*/
SearchHistoryManager(Activity activity, FrameLayout overlayContainer,
OnSelectHistoryItemListener onSelectHistoryItemAction) {
this.activity = activity;
this.showSettingsSearchHistory = SETTINGS_SEARCH_HISTORY.get();
this.searchHistory = new LinkedList<>();
// Initialize search history from settings.
if (showSettingsSearchHistory) {
String entries = SETTINGS_SEARCH_ENTRIES.get();
if (!entries.isBlank()) {
searchHistory.addAll(Arrays.asList(entries.split("\n")));
}
} else {
// Clear old saved history if the feature is disabled.
SETTINGS_SEARCH_ENTRIES.resetToDefault();
}
// Create search history container.
this.searchHistoryContainer = new FrameLayout(activity);
searchHistoryContainer.setVisibility(View.GONE);
// Inflate search history layout.
LayoutInflater inflater = LayoutInflater.from(activity);
View historyView = inflater.inflate(LAYOUT_REVANCED_PREFERENCE_SEARCH_HISTORY_SCREEN, searchHistoryContainer, false);
searchHistoryContainer.addView(historyView, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT));
// Add history container to overlay.
FrameLayout.LayoutParams overlayParams = new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT);
overlayParams.gravity = Gravity.TOP;
overlayContainer.addView(searchHistoryContainer, overlayParams);
// Find the LinearLayout for the history list within the container.
LinearLayout searchHistoryListView = searchHistoryContainer.findViewById(ID_SEARCH_HISTORY_LIST);
if (searchHistoryListView == null) {
throw new IllegalStateException("Search history list view not found in container");
}
// Set up history adapter. Use a copy of the search history.
this.searchHistoryAdapter = new SearchHistoryAdapter(activity, searchHistoryListView,
new ArrayList<>(searchHistory), onSelectHistoryItemAction);
// Set up clear history button.
TextView clearHistoryButton = searchHistoryContainer.findViewById(ID_CLEAR_HISTORY_BUTTON);
clearHistoryButton.setOnClickListener(v -> createAndShowDialog(
str("revanced_settings_search_clear_history"),
str("revanced_settings_search_clear_history_message"),
this::clearAllSearchHistory
));
// Set up search tips summary.
CharSequence text = BulletPointPreference.formatIntoBulletPoints(
str("revanced_settings_search_tips_summary"));
TextView tipsSummary = historyView.findViewById(ID_SEARCH_TIPS_SUMMARY);
tipsSummary.setText(text);
}
/**
* Shows search history screen - either with history items or empty history message.
*/
public void showSearchHistory() {
if (!showSettingsSearchHistory) {
return;
}
// Find all view elements.
TextView emptyHistoryTitle = searchHistoryContainer.findViewById(ID_EMPTY_HISTORY_TITLE);
TextView emptyHistorySummary = searchHistoryContainer.findViewById(ID_EMPTY_HISTORY_SUMMARY);
TextView historyHeader = searchHistoryContainer.findViewById(ID_SEARCH_HISTORY_HEADER);
LinearLayout historyList = searchHistoryContainer.findViewById(ID_SEARCH_HISTORY_LIST);
TextView clearHistoryButton = searchHistoryContainer.findViewById(ID_CLEAR_HISTORY_BUTTON);
if (searchHistory.isEmpty()) {
// Show empty history state.
showEmptyHistoryViews(emptyHistoryTitle, emptyHistorySummary);
hideHistoryViews(historyHeader, historyList, clearHistoryButton);
} else {
// Show history list state.
hideEmptyHistoryViews(emptyHistoryTitle, emptyHistorySummary);
showHistoryViews(historyHeader, historyList, clearHistoryButton);
// Update adapter with current history.
searchHistoryAdapter.clear();
searchHistoryAdapter.addAll(searchHistory);
searchHistoryAdapter.notifyDataSetChanged();
}
// Show the search history container.
showSearchHistoryContainer();
}
/**
* Saves a search query to the history, maintaining the size limit.
*/
public void saveSearchQuery(String query) {
if (!showSettingsSearchHistory) return;
searchHistory.remove(query); // Remove if already exists to update position.
searchHistory.addFirst(query); // Add to the most recent.
// Remove extra old entries.
while (searchHistory.size() > MAX_HISTORY_SIZE) {
String last = searchHistory.removeLast();
Logger.printDebug(() -> "Removing search history query: " + last);
}
saveSearchHistory();
}
/**
* Saves the search history to shared preferences.
*/
protected void saveSearchHistory() {
Logger.printDebug(() -> "Saving search history: " + searchHistory);
SETTINGS_SEARCH_ENTRIES.save(String.join("\n", searchHistory));
}
/**
* Removes a search query from the history.
*/
public void removeSearchQuery(String query) {
searchHistory.remove(query);
saveSearchHistory();
}
/**
* Clears all search history.
*/
public void clearAllSearchHistory() {
searchHistory.clear();
saveSearchHistory();
searchHistoryAdapter.clear();
searchHistoryAdapter.notifyDataSetChanged();
showSearchHistory();
}
/**
* Checks if search history feature is enabled.
*/
public boolean isSearchHistoryEnabled() {
return showSettingsSearchHistory;
}
/**
* Shows the search history container and overlay.
*/
public void showSearchHistoryContainer() {
searchHistoryContainer.setVisibility(View.VISIBLE);
}
/**
* Hides the search history container.
*/
public void hideSearchHistoryContainer() {
searchHistoryContainer.setVisibility(View.GONE);
}
/**
* Helper method to show empty history views.
*/
protected void showEmptyHistoryViews(TextView emptyTitle, TextView emptySummary) {
emptyTitle.setVisibility(View.VISIBLE);
emptyTitle.setText(str("revanced_settings_search_empty_history_title"));
emptySummary.setVisibility(View.VISIBLE);
emptySummary.setText(str("revanced_settings_search_empty_history_summary"));
}
/**
* Helper method to hide empty history views.
*/
protected void hideEmptyHistoryViews(TextView emptyTitle, TextView emptySummary) {
emptyTitle.setVisibility(View.GONE);
emptySummary.setVisibility(View.GONE);
}
/**
* Helper method to show history list views.
*/
protected void showHistoryViews(TextView header, LinearLayout list, TextView clearButton) {
header.setVisibility(View.VISIBLE);
list.setVisibility(View.VISIBLE);
clearButton.setVisibility(View.VISIBLE);
}
/**
* Helper method to hide history list views.
*/
protected void hideHistoryViews(TextView header, LinearLayout list, TextView clearButton) {
header.setVisibility(View.GONE);
list.setVisibility(View.GONE);
clearButton.setVisibility(View.GONE);
}
/**
* Creates and shows a dialog with the specified title, message, and confirmation action.
*
* @param title The title of the dialog.
* @param message The message to display in the dialog.
* @param confirmAction The action to perform when the dialog is confirmed.
*/
protected void createAndShowDialog(String title, String message, Runnable confirmAction) {
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
activity,
title,
message,
null,
null,
confirmAction,
() -> {},
null,
null,
false
);
Dialog dialog = dialogPair.first;
dialog.setCancelable(true);
dialog.show();
}
/**
* Custom adapter for search history items.
*/
protected class SearchHistoryAdapter {
protected final Collection<String> history;
protected final LayoutInflater inflater;
protected final LinearLayout container;
protected final OnSelectHistoryItemListener onSelectHistoryItemListener;
public SearchHistoryAdapter(Context context, LinearLayout container, Collection<String> history,
OnSelectHistoryItemListener listener) {
this.history = history;
this.inflater = LayoutInflater.from(context);
this.container = container;
this.onSelectHistoryItemListener = listener;
}
/**
* Updates the container with current history items.
*/
public void notifyDataSetChanged() {
container.removeAllViews();
for (String query : history) {
View view = inflater.inflate(LAYOUT_REVANCED_PREFERENCE_SEARCH_HISTORY_ITEM, container, false);
TextView historyText = view.findViewById(ID_HISTORY_TEXT);
ImageView deleteIcon = view.findViewById(ID_DELETE_ICON);
historyText.setText(query);
// Set click listener for main item (select query).
view.setOnClickListener(v -> onSelectHistoryItemListener.onSelectHistoryItem(query));
// Set click listener for delete icon.
deleteIcon.setOnClickListener(v -> createAndShowDialog(
query,
str("revanced_settings_search_remove_message"),
() -> {
removeSearchQuery(query);
remove(query);
notifyDataSetChanged();
}
));
container.addView(view);
}
}
/**
* Clears all views from the container and history list.
*/
public void clear() {
history.clear();
container.removeAllViews();
}
/**
* Adds all provided history items to the container.
*/
public void addAll(Collection<String> items) {
history.addAll(items);
notifyDataSetChanged();
}
/**
* Removes a query from the history and updates the container.
*/
public void remove(String query) {
history.remove(query);
if (history.isEmpty()) {
// If history is now empty, show the empty history state.
showSearchHistory();
} else {
notifyDataSetChanged();
}
}
}
}

View File

@@ -2,17 +2,22 @@ package app.revanced.extension.shared.spoof;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Locale;
import java.util.Objects;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.settings.BaseSettings;
@SuppressWarnings("ConstantLocale")
public enum ClientType {
/**
* Video not playable: Kids / Paid / Movie / Private / Age-restricted.
* This client can only be used when logged out.
*/
// https://dumps.tadiphone.dev/dumps/oculus/eureka
ANDROID_VR_NO_AUTH(
ANDROID_VR_1_61_48(
28,
"ANDROID_VR",
"com.google.android.apps.youtube.vr.oculus",
@@ -26,30 +31,31 @@ public enum ClientType {
"132.0.6808.3",
"1.61.48",
false,
false,
"Android VR No auth"
"Android VR 1.61"
),
// Chromecast with Google TV 4K.
// https://dumps.tadiphone.dev/dumps/google/kirkwood
ANDROID_UNPLUGGED(
29,
"ANDROID_UNPLUGGED",
"com.google.android.apps.youtube.unplugged",
"Google",
"Google TV Streamer",
"Android",
"14",
"34",
"UTT3.240625.001.K5",
"132.0.6808.3",
"8.49.0",
true,
true,
"Android TV"
/**
* Uses non adaptive bitrate, which fixes audio stuttering with YT Music.
* Does not use AV1.
*/
ANDROID_VR_1_43_32(
ANDROID_VR_1_61_48.id,
ANDROID_VR_1_61_48.clientName,
Objects.requireNonNull(ANDROID_VR_1_61_48.packageName),
ANDROID_VR_1_61_48.deviceMake,
ANDROID_VR_1_61_48.deviceModel,
ANDROID_VR_1_61_48.osName,
ANDROID_VR_1_61_48.osVersion,
Objects.requireNonNull(ANDROID_VR_1_61_48.androidSdkVersion),
Objects.requireNonNull(ANDROID_VR_1_61_48.buildId),
"107.0.5284.2",
"1.43.32",
ANDROID_VR_1_61_48.useAuth,
"Android VR 1.43"
),
// Cannot play livestreams and lacks HDR, but can play videos with music and labeled "for children".
// Google Pixel 9 Pro Fold
// https://dumps.tadiphone.dev/dumps/google/barbet
/**
* Cannot play livestreams and lacks HDR, but can play videos with music and labeled "for children".
* <a href="https://dumps.tadiphone.dev/dumps/google/barbet">Google Pixel 9 Pro Fold</a>
*/
ANDROID_CREATOR(
14,
"ANDROID_CREATOR",
@@ -63,61 +69,47 @@ public enum ClientType {
"132.0.6779.0",
"23.47.101",
true,
true,
"Android Creator"
"Android Studio"
),
IOS_UNPLUGGED(
33,
"IOS_UNPLUGGED",
"com.google.ios.youtubeunplugged",
/**
* Internal YT client for an unreleased YT client. May stop working at any time.
*/
VISIONOS(101,
"VISIONOS",
"Apple",
forceAVC()
// 11 Pro Max (last device with iOS 13)
? "iPhone12,5"
// 15 Pro Max
: "iPhone16,2",
"iOS",
forceAVC()
// iOS 13 and earlier uses only AVC. 14+ adds VP9 and AV1.
? "13.7.17H35"
: "18.2.22C152",
null,
null,
null,
// Version number should be a valid iOS release.
// https://www.ipa4fun.com/history/152043/
forceAVC()
// Some newer versions can also force AVC,
// but 6.45 is the last version that supports iOS 13.
? "6.45"
: "8.49",
true,
true,
forceAVC()
? "iOS TV Force AVC"
: "iOS TV"
"RealityDevice14,1",
"visionOS",
"1.3.21O771",
"0.1",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15",
false,
"visionOS"
),
ANDROID_VR_AUTH(
ANDROID_VR_NO_AUTH.id,
ANDROID_VR_NO_AUTH.clientName,
ANDROID_VR_NO_AUTH.packageName,
ANDROID_VR_NO_AUTH.deviceMake,
ANDROID_VR_NO_AUTH.deviceModel,
ANDROID_VR_NO_AUTH.osName,
ANDROID_VR_NO_AUTH.osVersion,
ANDROID_VR_NO_AUTH.androidSdkVersion,
ANDROID_VR_NO_AUTH.buildId,
ANDROID_VR_NO_AUTH.cronetVersion,
ANDROID_VR_NO_AUTH.clientVersion,
ANDROID_VR_NO_AUTH.requiresAuth,
true,
"Android VR Auth"
/**
* The device machine id for the iPad 6th Gen (iPad7,6).
* AV1 hardware decoding is not supported.
* See [this GitHub Gist](https://gist.github.com/adamawolf/3048717) for more information.
*
* Based on Google's actions to date, PoToken may not be required on devices with very low specs.
* For example, suppose the User-Agent for a PlayStation 3 (with 256MB of RAM) is used.
* Accessing 'Web' (https://www.youtube.com) will redirect to 'TV' (https://www.youtube.com/tv).
* 'TV' target devices with very low specs, such as embedded devices, game consoles, and blu-ray players, so PoToken is not required.
*
* For this reason, the device machine id for the iPad 6th Gen (with 2GB of RAM),
* the lowest spec device capable of running iPadOS 17, was used.
*/
IPADOS(5,
"IOS",
"Apple",
"iPad7,6",
"iPadOS",
"17.7.10.21H450",
"19.22.3",
"com.google.ios.youtube/19.22.3 (iPad7,6; U; CPU iPadOS 17_7_10 like Mac OS X; " + Locale.getDefault() + ")",
false,
"iPadOS"
);
private static boolean forceAVC() {
return BaseSettings.SPOOF_VIDEO_STREAMS_IOS_FORCE_AVC.get();
}
/**
* YouTube
* <a href="https://github.com/zerodytrash/YouTube-Internal-Clients?tab=readme-ov-file#clients">client type</a>
@@ -129,6 +121,7 @@ public enum ClientType {
/**
* App package name.
*/
@Nullable
private final String packageName;
/**
@@ -182,12 +175,6 @@ public enum ClientType {
*/
public final String clientVersion;
/**
* If this client requires authentication and does not work
* if logged out or in incognito mode.
*/
public final boolean requiresAuth;
/**
* If the client should use authentication if available.
*/
@@ -198,19 +185,20 @@ public enum ClientType {
*/
public final String friendlyName;
@SuppressWarnings("ConstantLocale")
/**
* Android constructor.
*/
ClientType(int id,
String clientName,
String packageName,
@NonNull String packageName,
String deviceMake,
String deviceModel,
String osName,
String osVersion,
@Nullable String androidSdkVersion,
@Nullable String buildId,
@Nullable String cronetVersion,
@NonNull String androidSdkVersion,
@NonNull String buildId,
@NonNull String cronetVersion,
String clientVersion,
boolean requiresAuth,
boolean useAuth,
String friendlyName) {
this.id = id;
@@ -224,36 +212,46 @@ public enum ClientType {
this.buildId = buildId;
this.cronetVersion = cronetVersion;
this.clientVersion = clientVersion;
this.requiresAuth = requiresAuth;
this.useAuth = useAuth;
this.friendlyName = friendlyName;
Locale defaultLocale = Locale.getDefault();
if (androidSdkVersion == null) {
// Convert version from '18.2.22C152' into '18_2_22'
String userAgentOsVersion = osVersion
.replaceAll("(\\d+\\.\\d+\\.\\d+).*", "$1")
.replace(".", "_");
// https://github.com/mitmproxy/mitmproxy/issues/4836
this.userAgent = String.format("%s/%s (%s; U; CPU iOS %s like Mac OS X; %s)",
packageName,
clientVersion,
deviceModel,
userAgentOsVersion,
defaultLocale
);
} else {
this.userAgent = String.format("%s/%s (Linux; U; Android %s; %s; %s; Build/%s; Cronet/%s)",
packageName,
clientVersion,
osVersion,
defaultLocale,
deviceModel,
Objects.requireNonNull(buildId),
Objects.requireNonNull(cronetVersion)
);
}
this.userAgent = String.format("%s/%s (Linux; U; Android %s; %s; %s; Build/%s; Cronet/%s)",
packageName,
clientVersion,
osVersion,
defaultLocale,
deviceModel,
Objects.requireNonNull(buildId),
Objects.requireNonNull(cronetVersion)
);
Logger.printDebug(() -> "userAgent: " + this.userAgent);
}
@SuppressWarnings("ConstantLocale")
ClientType(int id,
String clientName,
String deviceMake,
String deviceModel,
String osName,
String osVersion,
String clientVersion,
String userAgent,
boolean useAuth,
String friendlyName) {
this.id = id;
this.clientName = clientName;
this.deviceMake = deviceMake;
this.deviceModel = deviceModel;
this.osName = osName;
this.osVersion = osVersion;
this.clientVersion = clientVersion;
this.userAgent = userAgent;
this.useAuth = useAuth;
this.friendlyName = friendlyName;
this.packageName = null;
this.androidSdkVersion = null;
this.buildId = null;
this.cronetVersion = null;
}
}

View File

@@ -6,38 +6,70 @@ import android.text.TextUtils;
import androidx.annotation.Nullable;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.AppLanguage;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.shared.spoof.requests.StreamingDataRequest;
@SuppressWarnings("unused")
public class SpoofVideoStreamsPatch {
/**
* Domain used for internet connectivity verification.
* It has an empty response body and is only used to check for a 204 response code.
* <p>
* If an unreachable IP address (127.0.0.1) is used, no response code is provided.
* <p>
* YouTube handles unreachable IP addresses without issue.
* YouTube Music has an issue with waiting for the Cronet connect timeout of 30s on mobile networks.
* <p>
* Using a VPN or DNS can temporarily resolve this issue,
* But the ideal workaround is to avoid using an unreachable IP address.
*/
private static final String INTERNET_CONNECTION_CHECK_URI_STRING = "https://www.google.com/gen_204";
private static final Uri INTERNET_CONNECTION_CHECK_URI = Uri.parse(INTERNET_CONNECTION_CHECK_URI_STRING);
private static final boolean SPOOF_STREAMING_DATA = BaseSettings.SPOOF_VIDEO_STREAMS.get();
private static final boolean FIX_HLS_CURRENT_TIME = SPOOF_STREAMING_DATA
&& BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.IOS_UNPLUGGED;
@Nullable
private static volatile AppLanguage languageOverride;
/**
* Any unreachable ip address. Used to intentionally fail requests.
*/
private static final String UNREACHABLE_HOST_URI_STRING = "https://127.0.0.0";
private static final Uri UNREACHABLE_HOST_URI = Uri.parse(UNREACHABLE_HOST_URI_STRING);
private static volatile ClientType preferredClient = ClientType.ANDROID_VR_1_61_48;
/**
* @return If this patch was included during patching.
*/
private static boolean isPatchIncluded() {
public static boolean isPatchIncluded() {
return false; // Modified during patching.
}
public static boolean notSpoofingToAndroid() {
return !isPatchIncluded()
|| !BaseSettings.SPOOF_VIDEO_STREAMS.get()
|| BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.IOS_UNPLUGGED;
@Nullable
public static AppLanguage getLanguageOverride() {
return languageOverride;
}
/**
* @param language Language override for non-authenticated requests. If this is null then
* {@link BaseSettings#SPOOF_VIDEO_STREAMS_LANGUAGE} is used.
*/
public static void setLanguageOverride(@Nullable AppLanguage language) {
languageOverride = language;
}
public static void setClientsToUse(List<ClientType> availableClients, ClientType client) {
preferredClient = Objects.requireNonNull(client);
StreamingDataRequest.setClientOrderToUse(availableClients, client);
}
public static boolean spoofingToClientWithNoMultiAudioStreams() {
return isPatchIncluded()
&& SPOOF_STREAMING_DATA
&& preferredClient != ClientType.IPADOS;
}
/**
@@ -53,9 +85,9 @@ public class SpoofVideoStreamsPatch {
String path = playerRequestUri.getPath();
if (path != null && path.contains("get_watch")) {
Logger.printDebug(() -> "Blocking 'get_watch' by returning unreachable uri");
Logger.printDebug(() -> "Blocking 'get_watch' by returning internet connection check uri");
return UNREACHABLE_HOST_URI;
return INTERNET_CONNECTION_CHECK_URI;
}
} catch (Exception ex) {
Logger.printException(() -> "blockGetWatchRequest failure", ex);
@@ -65,6 +97,35 @@ public class SpoofVideoStreamsPatch {
return playerRequestUri;
}
/**
* Injection point.
*
* Blocks /get_watch requests by returning an unreachable URI.
* /att/get requests are used to obtain a PoToken challenge.
* See: <a href="https://github.com/FreeTubeApp/FreeTube/blob/4b7208430bc1032019a35a35eb7c8a84987ddbd7/src/botGuardScript.js#L15">botGuardScript.js#L15</a>
* <p>
* Since the Spoof streaming data patch was implemented because a valid PoToken cannot be obtained,
* Blocking /att/get requests are not a problem.
*/
public static String blockGetAttRequest(String originalUrlString) {
if (SPOOF_STREAMING_DATA) {
try {
var originalUri = Uri.parse(originalUrlString);
String path = originalUri.getPath();
if (path != null && path.contains("att/get")) {
Logger.printDebug(() -> "Blocking 'att/get' by returning internet connection check uri");
return INTERNET_CONNECTION_CHECK_URI_STRING;
}
} catch (Exception ex) {
Logger.printException(() -> "blockGetAttRequest failure", ex);
}
}
return originalUrlString;
}
/**
* Injection point.
* <p>
@@ -77,9 +138,9 @@ public class SpoofVideoStreamsPatch {
String path = originalUri.getPath();
if (path != null && path.contains("initplayback")) {
Logger.printDebug(() -> "Blocking 'initplayback' by clearing query");
Logger.printDebug(() -> "Blocking 'initplayback' by returning internet connection check uri");
return originalUri.buildUpon().clearQuery().build().toString();
return INTERNET_CONNECTION_CHECK_URI_STRING;
}
} catch (Exception ex) {
Logger.printException(() -> "blockInitPlaybackRequest failure", ex);
@@ -98,7 +159,7 @@ public class SpoofVideoStreamsPatch {
/**
* Injection point.
* Only invoked when playing a livestream on an iOS client.
* Only invoked when playing a livestream on an Apple client.
*/
public static boolean fixHLSCurrentTime(boolean original) {
if (!SPOOF_STREAMING_DATA) {
@@ -107,6 +168,14 @@ public class SpoofVideoStreamsPatch {
return false;
}
/*
* Injection point.
* Fix audio stuttering in YouTube Music.
*/
public static boolean disableSABR() {
return SPOOF_STREAMING_DATA;
}
/**
* Injection point.
* Turns off a feature flag that interferes with spoofing.
@@ -252,16 +321,7 @@ public class SpoofVideoStreamsPatch {
public static final class AudioStreamLanguageOverrideAvailability implements Setting.Availability {
@Override
public boolean isAvailable() {
return BaseSettings.SPOOF_VIDEO_STREAMS.get()
&& BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.ANDROID_VR_NO_AUTH;
}
}
public static final class SpoofiOSAvailability implements Setting.Availability {
@Override
public boolean isAvailable() {
return BaseSettings.SPOOF_VIDEO_STREAMS.get()
&& BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.IOS_UNPLUGGED;
return BaseSettings.SPOOF_VIDEO_STREAMS.get() && !preferredClient.useAuth;
}
}
}

View File

@@ -1,5 +1,7 @@
package app.revanced.extension.shared.spoof.requests;
import static app.revanced.extension.shared.spoof.ClientType.ANDROID_VR_1_43_32;
import org.json.JSONException;
import org.json.JSONObject;
@@ -10,8 +12,10 @@ import java.util.Locale;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.requests.Requester;
import app.revanced.extension.shared.requests.Route;
import app.revanced.extension.shared.settings.AppLanguage;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.spoof.ClientType;
import app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch;
final class PlayerRoutes {
static final Route.CompiledRoute GET_STREAMING_DATA = new Route(
@@ -37,14 +41,16 @@ final class PlayerRoutes {
try {
JSONObject context = new JSONObject();
// Can override default language only if no login is used.
// Could use preferred audio for all clients that do not login,
// but if this is a fall over client it will set the language even though
// the audio language is not selectable in the UI.
ClientType userSelectedClient = BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get();
Locale streamLocale = userSelectedClient == ClientType.ANDROID_VR_NO_AUTH
? BaseSettings.SPOOF_VIDEO_STREAMS_LANGUAGE.get().getLocale()
: Locale.getDefault();
AppLanguage language = SpoofVideoStreamsPatch.getLanguageOverride();
if (language == null || clientType == ANDROID_VR_1_43_32) {
// Force original audio has not overrode the language.
// Or if YT has fallen over to the last unauthenticated client (VR 1.43), then
// always use the app language because forcing an audio stream of specific languages
// can sometimes fail so it's better to try and load something rather than nothing.
language = BaseSettings.SPOOF_VIDEO_STREAMS_LANGUAGE.get();
}
//noinspection ExtractMethodRecommender
Locale streamLocale = language.getLocale();
JSONObject client = new JSONObject();
client.put("deviceMake", clientType.deviceMake);

View File

@@ -1,5 +1,6 @@
package app.revanced.extension.shared.spoof.requests;
import static app.revanced.extension.shared.ByteTrieSearch.convertStringsToBytes;
import static app.revanced.extension.shared.spoof.requests.PlayerRoutes.GET_STREAMING_DATA;
import androidx.annotation.NonNull;
@@ -13,12 +14,18 @@ import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import app.revanced.extension.shared.ByteTrieSearch;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.BaseSettings;
@@ -35,21 +42,27 @@ import app.revanced.extension.shared.spoof.ClientType;
*/
public class StreamingDataRequest {
private static final ClientType[] CLIENT_ORDER_TO_USE;
private static volatile ClientType[] clientOrderToUse = ClientType.values();
static {
ClientType[] allClientTypes = ClientType.values();
ClientType preferredClient = BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get();
public static void setClientOrderToUse(List<ClientType> availableClients, ClientType preferredClient) {
Objects.requireNonNull(preferredClient);
CLIENT_ORDER_TO_USE = new ClientType[allClientTypes.length];
CLIENT_ORDER_TO_USE[0] = preferredClient;
int availableClientSize = availableClients.size();
if (!availableClients.contains(preferredClient)) {
availableClientSize++;
}
clientOrderToUse = new ClientType[availableClientSize];
clientOrderToUse[0] = preferredClient;
int i = 1;
for (ClientType c : allClientTypes) {
for (ClientType c : availableClients) {
if (c != preferredClient) {
CLIENT_ORDER_TO_USE[i++] = c;
clientOrderToUse[i++] = c;
}
}
Logger.printDebug(() -> "Available spoof clients: " + Arrays.toString(clientOrderToUse));
}
private static final String AUTHORIZATION_HEADER = "Authorization";
@@ -87,6 +100,16 @@ public class StreamingDataRequest {
}
});
/**
* Strings found in the response if the video is a livestream.
*/
private static final ByteTrieSearch liveStreamBufferSearch = new ByteTrieSearch(
convertStringsToBytes(
"yt_live_broadcast",
"yt_premiere_broadcast"
)
);
private static volatile ClientType lastSpoofedClientType;
public static String getLastSpoofedClientName() {
@@ -154,7 +177,7 @@ public class StreamingDataRequest {
}
}
if (!authHeadersIncludes && clientType.requiresAuth) {
if (!authHeadersIncludes && clientType.useAuth) {
Logger.printDebug(() -> "Skipping client since user is not logged in: " + clientType
+ " videoId: " + videoId);
return null;
@@ -193,9 +216,9 @@ public class StreamingDataRequest {
// Retry with different client if empty response body is received.
int i = 0;
for (ClientType clientType : CLIENT_ORDER_TO_USE) {
for (ClientType clientType : clientOrderToUse) {
// Show an error if the last client type fails, or if debug is enabled then show for all attempts.
final boolean showErrorToast = (++i == CLIENT_ORDER_TO_USE.length) || debugEnabled;
final boolean showErrorToast = (++i == clientOrderToUse.length) || debugEnabled;
HttpURLConnection connection = send(clientType, videoId, playerHeaders, showErrorToast);
if (connection != null) {
@@ -215,9 +238,13 @@ public class StreamingDataRequest {
while ((bytesRead = inputStream.read(buffer)) >= 0) {
baos.write(buffer, 0, bytesRead);
}
lastSpoofedClientType = clientType;
if (clientType == ClientType.ANDROID_CREATOR && liveStreamBufferSearch.matches(buffer)) {
Logger.printDebug(() -> "Skipping Android Studio as video is a livestream: " + videoId);
} else {
lastSpoofedClientType = clientType;
return ByteBuffer.wrap(baos.toByteArray());
return ByteBuffer.wrap(baos.toByteArray());
}
}
}
} catch (IOException ex) {

View File

@@ -0,0 +1,61 @@
package app.revanced.extension.shared.ui;
import static app.revanced.extension.shared.Utils.adjustColorBrightness;
import static app.revanced.extension.shared.Utils.dipToPixels;
import static app.revanced.extension.shared.Utils.getAppBackgroundColor;
import static app.revanced.extension.shared.Utils.isDarkModeEnabled;
import static app.revanced.extension.shared.settings.preference.ColorPickerPreference.DISABLED_ALPHA;
import android.graphics.Color;
import android.graphics.drawable.GradientDrawable;
import android.view.View;
import androidx.annotation.ColorInt;
public class ColorDot {
private static final int STROKE_WIDTH = dipToPixels(1.5f); // Stroke width in dp.
/**
* Creates a circular drawable with a main fill and a stroke.
* Stroke adapts to dark/light theme and transparency, applied only when color is transparent or matches app background.
*/
public static GradientDrawable createColorDotDrawable(@ColorInt int color) {
final boolean isDarkTheme = isDarkModeEnabled();
final boolean isTransparent = Color.alpha(color) == 0;
final int opaqueColor = color | 0xFF000000;
final int appBackground = getAppBackgroundColor();
final int strokeColor;
final int strokeWidth;
// Determine stroke color.
if (isTransparent || (opaqueColor == appBackground)) {
final int baseColor = isTransparent ? appBackground : opaqueColor;
strokeColor = adjustColorBrightness(baseColor, isDarkTheme ? 1.2f : 0.8f);
strokeWidth = STROKE_WIDTH;
} else {
strokeColor = 0;
strokeWidth = 0;
}
// Create circular drawable with conditional stroke.
GradientDrawable circle = new GradientDrawable();
circle.setShape(GradientDrawable.OVAL);
circle.setColor(color);
circle.setStroke(strokeWidth, strokeColor);
return circle;
}
/**
* Applies the color dot drawable to the target view.
*/
public static void applyColorDot(View targetView, @ColorInt int color, boolean enabled) {
if (targetView == null) return;
targetView.setBackground(createColorDotDrawable(color));
targetView.setAlpha(enabled ? 1.0f : DISABLED_ALPHA);
if (!isDarkModeEnabled()) {
targetView.setClipToOutline(true);
targetView.setElevation(dipToPixels(2));
}
}
}

View File

@@ -0,0 +1,472 @@
package app.revanced.extension.shared.ui;
import static app.revanced.extension.shared.Utils.dipToPixels;
import android.app.Dialog;
import android.content.Context;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.ShapeDrawable;
import android.graphics.drawable.shapes.RoundRectShape;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.util.Pair;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import androidx.annotation.Nullable;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
/**
* A utility class for creating a customizable dialog with a title, message or EditText, and up to three buttons (OK, Cancel, Neutral).
* The dialog supports themed colors, rounded corners, and dynamic button layout based on screen width. It is dismissible by default.
*/
public class CustomDialog {
private final Context context;
private final Dialog dialog;
private final LinearLayout mainLayout;
private final int dip4, dip8, dip16, dip24, dip36;
/**
* Creates a custom dialog with a styled layout, including a title, message, buttons, and an optional EditText.
* The dialog's appearance adapts to the app's dark mode setting, with rounded corners and customizable button actions.
* Buttons adjust dynamically to their text content and are arranged in a single row if they fit within 80% of the
* screen width, with the Neutral button aligned to the left and OK/Cancel buttons centered on the right.
* If buttons do not fit, each is placed on a separate row, all aligned to the right.
*
* @param context Context used to create the dialog.
* @param title Title text of the dialog.
* @param message Message text of the dialog (supports Spanned for HTML), or null if replaced by EditText.
* @param editText EditText to include in the dialog, or null if no EditText is needed.
* @param okButtonText OK button text, or null to use the default "OK" string.
* @param onOkClick Action to perform when the OK button is clicked.
* @param onCancelClick Action to perform when the Cancel button is clicked, or null if no Cancel button is needed.
* @param neutralButtonText Neutral button text, or null if no Neutral button is needed.
* @param onNeutralClick Action to perform when the Neutral button is clicked, or null if no Neutral button is needed.
* @param dismissDialogOnNeutralClick If the dialog should be dismissed when the Neutral button is clicked.
* @return The Dialog and its main LinearLayout container.
*/
public static Pair<Dialog, LinearLayout> create(Context context, String title, CharSequence message,
@Nullable EditText editText, String okButtonText,
Runnable onOkClick, Runnable onCancelClick,
@Nullable String neutralButtonText,
@Nullable Runnable onNeutralClick,
boolean dismissDialogOnNeutralClick) {
Logger.printDebug(() -> "Creating custom dialog with title: " + title);
CustomDialog customDialog = new CustomDialog(context, title, message, editText,
okButtonText, onOkClick, onCancelClick,
neutralButtonText, onNeutralClick, dismissDialogOnNeutralClick);
return new Pair<>(customDialog.dialog, customDialog.mainLayout);
}
/**
* Initializes a custom dialog with the specified parameters.
*
* @param context Context used to create the dialog.
* @param title Title text of the dialog.
* @param message Message text of the dialog, or null if replaced by EditText.
* @param editText EditText to include in the dialog, or null if no EditText is needed.
* @param okButtonText OK button text, or null to use the default "OK" string.
* @param onOkClick Action to perform when the OK button is clicked.
* @param onCancelClick Action to perform when the Cancel button is clicked, or null if no Cancel button is needed.
* @param neutralButtonText Neutral button text, or null if no Neutral button is needed.
* @param onNeutralClick Action to perform when the Neutral button is clicked, or null if no Neutral button is needed.
* @param dismissDialogOnNeutralClick If the dialog should be dismissed when the Neutral button is clicked.
*/
private CustomDialog(Context context, String title, CharSequence message, @Nullable EditText editText,
String okButtonText, Runnable onOkClick, Runnable onCancelClick,
@Nullable String neutralButtonText, @Nullable Runnable onNeutralClick,
boolean dismissDialogOnNeutralClick) {
this.context = context;
this.dialog = new Dialog(context);
this.dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); // Remove default title bar.
// Preset size constants.
dip4 = dipToPixels(4);
dip8 = dipToPixels(8);
dip16 = dipToPixels(16);
dip24 = dipToPixels(24);
dip36 = dipToPixels(36);
// Create main layout.
mainLayout = createMainLayout();
addTitle(title);
addContent(message, editText);
addButtons(okButtonText, onOkClick, onCancelClick, neutralButtonText, onNeutralClick, dismissDialogOnNeutralClick);
// Set dialog content and window attributes.
dialog.setContentView(mainLayout);
Window window = dialog.getWindow();
if (window != null) {
Utils.setDialogWindowParameters(window, Gravity.CENTER, 0, 90, false);
}
}
/**
* Creates the main layout for the dialog with vertical orientation and rounded corners.
*
* @return The configured LinearLayout for the dialog.
*/
private LinearLayout createMainLayout() {
LinearLayout layout = new LinearLayout(context);
layout.setOrientation(LinearLayout.VERTICAL);
layout.setPadding(dip24, dip16, dip24, dip24);
// Set rounded rectangle background.
ShapeDrawable background = new ShapeDrawable(new RoundRectShape(
Utils.createCornerRadii(28), null, null));
// Dialog background.
background.getPaint().setColor(Utils.getDialogBackgroundColor());
layout.setBackground(background);
return layout;
}
/**
* Adds a title to the dialog if provided.
*
* @param title The title text to display.
*/
private void addTitle(String title) {
if (TextUtils.isEmpty(title)) return;
TextView titleView = new TextView(context);
titleView.setText(title);
titleView.setTypeface(Typeface.DEFAULT_BOLD);
titleView.setTextSize(18);
titleView.setTextColor(Utils.getAppForegroundColor());
titleView.setGravity(Gravity.CENTER);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
params.setMargins(0, 0, 0, dip16);
titleView.setLayoutParams(params);
mainLayout.addView(titleView);
}
/**
* Adds a message or EditText to the dialog within a ScrollView.
*
* @param message The message text to display (supports Spanned for HTML), or null if replaced by EditText.
* @param editText The EditText to include, or null if no EditText is needed.
*/
private void addContent(CharSequence message, @Nullable EditText editText) {
// Create content container (message/EditText) inside a ScrollView only if message or editText is provided.
if (message == null && editText == null) return;
ScrollView scrollView = new ScrollView(context);
// Disable the vertical scrollbar.
scrollView.setVerticalScrollBarEnabled(false);
scrollView.setOverScrollMode(View.OVER_SCROLL_NEVER);
LinearLayout contentContainer = new LinearLayout(context);
contentContainer.setOrientation(LinearLayout.VERTICAL);
scrollView.addView(contentContainer);
// EditText (if provided).
if (editText != null) {
ShapeDrawable background = new ShapeDrawable(new RoundRectShape(
Utils.createCornerRadii(10), null, null));
background.getPaint().setColor(Utils.getEditTextBackground());
scrollView.setPadding(dip8, dip8, dip8, dip8);
scrollView.setBackground(background);
scrollView.setClipToOutline(true);
// Remove EditText from its current parent, if any.
ViewGroup parent = (ViewGroup) editText.getParent();
if (parent != null) parent.removeView(editText);
// Style the EditText to match the dialog theme.
editText.setTextColor(Utils.getAppForegroundColor());
editText.setBackgroundColor(Color.TRANSPARENT);
editText.setPadding(0, 0, 0, 0);
contentContainer.addView(editText, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT));
// Message (if not replaced by EditText).
} else {
TextView messageView = new TextView(context);
// Supports Spanned (HTML).
messageView.setText(message);
messageView.setTextSize(16);
messageView.setTextColor(Utils.getAppForegroundColor());
// Enable HTML link clicking if the message contains links.
if (message instanceof Spanned) {
messageView.setMovementMethod(LinkMovementMethod.getInstance());
}
contentContainer.addView(messageView, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT));
}
// Weight to take available space.
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1.0f);
scrollView.setLayoutParams(params);
// Add ScrollView to main layout only if content exist.
mainLayout.addView(scrollView);
}
/**
* Adds buttons to the dialog, arranging them dynamically based on their widths.
*
* @param okButtonText OK button text, or null to use the default "OK" string.
* @param onOkClick Action for the OK button click.
* @param onCancelClick Action for the Cancel button click, or null if no Cancel button.
* @param neutralButtonText Neutral button text, or null if no Neutral button.
* @param onNeutralClick Action for the Neutral button click, or null if no Neutral button.
* @param dismissDialogOnNeutralClick If the dialog should dismiss on Neutral button click.
*/
private void addButtons(String okButtonText, Runnable onOkClick, Runnable onCancelClick,
@Nullable String neutralButtonText, @Nullable Runnable onNeutralClick,
boolean dismissDialogOnNeutralClick) {
// Button container.
LinearLayout buttonContainer = new LinearLayout(context);
buttonContainer.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams buttonContainerParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT);
buttonContainerParams.setMargins(0, dip16, 0, 0);
buttonContainer.setLayoutParams(buttonContainerParams);
List<Button> buttons = new ArrayList<>();
List<Integer> buttonWidths = new ArrayList<>();
// Create buttons in order: Neutral, Cancel, OK.
if (neutralButtonText != null && onNeutralClick != null) {
Button neutralButton = createButton(neutralButtonText, onNeutralClick, false, dismissDialogOnNeutralClick);
buttons.add(neutralButton);
buttonWidths.add(measureButtonWidth(neutralButton));
}
if (onCancelClick != null) {
Button cancelButton = createButton(context.getString(android.R.string.cancel), onCancelClick, false, true);
buttons.add(cancelButton);
buttonWidths.add(measureButtonWidth(cancelButton));
}
if (onOkClick != null) {
Button okButton = createButton(
okButtonText != null ? okButtonText : context.getString(android.R.string.ok),
onOkClick, true, true);
buttons.add(okButton);
buttonWidths.add(measureButtonWidth(okButton));
}
// Handle button layout.
layoutButtons(buttonContainer, buttons, buttonWidths);
mainLayout.addView(buttonContainer);
}
/**
* Creates a styled button with customizable text, click behavior, and appearance.
*
* @param text The button text to display.
* @param onClick The action to perform on button click.
* @param isOkButton If this is the OK button, which uses distinct styling.
* @param dismissDialog If the dialog should dismiss when the button is clicked.
* @return The created Button.
*/
private Button createButton(String text, Runnable onClick, boolean isOkButton, boolean dismissDialog) {
Button button = new Button(context, null, 0);
button.setText(text);
button.setTextSize(14);
button.setAllCaps(false);
button.setSingleLine(true);
button.setEllipsize(TextUtils.TruncateAt.END);
button.setGravity(Gravity.CENTER);
// Set internal padding.
button.setPadding(dip16, 0, dip16, 0);
// Background color for OK button (inversion).
// Background color for Cancel or Neutral buttons.
ShapeDrawable background = new ShapeDrawable(new RoundRectShape(
Utils.createCornerRadii(20), null, null));
background.getPaint().setColor(isOkButton
? Utils.getOkButtonBackgroundColor()
: Utils.getCancelOrNeutralButtonBackgroundColor());
button.setBackground(background);
button.setTextColor(Utils.isDarkModeEnabled()
? (isOkButton ? Color.BLACK : Color.WHITE)
: (isOkButton ? Color.WHITE : Color.BLACK));
button.setOnClickListener(v -> {
if (onClick != null) onClick.run();
if (dismissDialog) dialog.dismiss();
});
return button;
}
/**
* Measures the width of a button.
*/
private int measureButtonWidth(Button button) {
button.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED);
return button.getMeasuredWidth();
}
/**
* Arranges buttons in the dialog, either in a single row or multiple rows based on their total width.
*
* @param buttonContainer The container for the buttons.
* @param buttons The list of buttons to arrange.
* @param buttonWidths The measured widths of the buttons.
*/
private void layoutButtons(LinearLayout buttonContainer, List<Button> buttons, List<Integer> buttonWidths) {
if (buttons.isEmpty()) return;
// Check if buttons fit in one row.
int screenWidth = context.getResources().getDisplayMetrics().widthPixels;
int totalWidth = 0;
for (Integer width : buttonWidths) {
totalWidth += width;
}
if (buttonWidths.size() > 1) {
// Add margins for gaps.
totalWidth += (buttonWidths.size() - 1) * dip8;
}
// Single button: stretch to full width.
if (buttons.size() == 1) {
layoutSingleButton(buttonContainer, buttons.get(0));
} else if (totalWidth <= screenWidth * 0.8) {
// Single row: Neutral, Cancel, OK.
layoutButtonsInRow(buttonContainer, buttons, buttonWidths);
} else {
// Multiple rows: OK, Cancel, Neutral.
layoutButtonsInColumns(buttonContainer, buttons);
}
}
/**
* Arranges a single button, stretching it to full width.
*
* @param buttonContainer The container for the button.
* @param button The button to arrange.
*/
private void layoutSingleButton(LinearLayout buttonContainer, Button button) {
LinearLayout singleContainer = new LinearLayout(context);
singleContainer.setOrientation(LinearLayout.HORIZONTAL);
singleContainer.setGravity(Gravity.CENTER);
ViewGroup parent = (ViewGroup) button.getParent();
if (parent != null) parent.removeView(button);
button.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dip36));
singleContainer.addView(button);
buttonContainer.addView(singleContainer);
}
/**
* Arranges buttons in a single horizontal row with proportional widths.
*
* @param buttonContainer The container for the buttons.
* @param buttons The list of buttons to arrange.
* @param buttonWidths The measured widths of the buttons.
*/
private void layoutButtonsInRow(LinearLayout buttonContainer, List<Button> buttons, List<Integer> buttonWidths) {
LinearLayout rowContainer = new LinearLayout(context);
rowContainer.setOrientation(LinearLayout.HORIZONTAL);
rowContainer.setGravity(Gravity.CENTER);
rowContainer.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT));
// Add all buttons with proportional weights and specific margins.
for (int i = 0; i < buttons.size(); i++) {
Button button = getButton(buttons, buttonWidths, i);
rowContainer.addView(button);
}
buttonContainer.addView(rowContainer);
}
@NotNull
private Button getButton(List<Button> buttons, List<Integer> buttonWidths, int i) {
Button button = buttons.get(i);
ViewGroup parent = (ViewGroup) button.getParent();
if (parent != null) parent.removeView(button);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
0, dip36, buttonWidths.get(i));
// Set margins based on button type and combination.
if (buttons.size() == 2) {
// Neutral + OK or Cancel + OK.
params.setMargins(i == 0 ? 0 : dip4, 0, i == 0 ? dip4 : 0, 0);
} else if (buttons.size() == 3) {
// Neutral.
// Cancel.
// OK.
params.setMargins(i == 0 ? 0 : dip4, 0, i == 2 ? 0 : dip4, 0);
}
button.setLayoutParams(params);
return button;
}
/**
* Arranges buttons in separate rows, ordered OK, Cancel, Neutral.
*
* @param buttonContainer The container for the buttons.
* @param buttons The list of buttons to arrange.
*/
private void layoutButtonsInColumns(LinearLayout buttonContainer, List<Button> buttons) {
// Reorder: OK, Cancel, Neutral.
List<Button> reorderedButtons = new ArrayList<>();
if (buttons.size() == 3) {
reorderedButtons.add(buttons.get(2)); // OK
reorderedButtons.add(buttons.get(1)); // Cancel
reorderedButtons.add(buttons.get(0)); // Neutral
} else if (buttons.size() == 2) {
reorderedButtons.add(buttons.get(1)); // OK or Cancel
reorderedButtons.add(buttons.get(0)); // Neutral or Cancel
}
for (int i = 0; i < reorderedButtons.size(); i++) {
Button button = reorderedButtons.get(i);
LinearLayout singleContainer = new LinearLayout(context);
singleContainer.setOrientation(LinearLayout.HORIZONTAL);
singleContainer.setGravity(Gravity.CENTER);
singleContainer.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dip36));
ViewGroup parent = (ViewGroup) button.getParent();
if (parent != null) parent.removeView(button);
button.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dip36));
singleContainer.addView(button);
buttonContainer.addView(singleContainer);
// Add a spacer between the buttons (except the last one).
if (i < reorderedButtons.size() - 1) {
View spacer = new View(context);
LinearLayout.LayoutParams spacerParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dip8);
spacer.setLayoutParams(spacerParams);
buttonContainer.addView(spacer);
}
}
}
}

View File

@@ -0,0 +1,463 @@
package app.revanced.extension.shared.ui;
import static app.revanced.extension.shared.Utils.dipToPixels;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.annotation.SuppressLint;
import android.app.Dialog;
import android.content.Context;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.view.animation.DecelerateInterpolator;
import android.widget.LinearLayout;
import android.graphics.drawable.ShapeDrawable;
import android.graphics.drawable.shapes.RoundRectShape;
import android.widget.ScrollView;
import android.widget.ListView;
import android.widget.Scroller;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import app.revanced.extension.shared.Utils;
/**
* A utility class for creating a bottom sheet dialog that slides up from the bottom of the screen.
* The dialog supports drag-to-dismiss functionality, animations, and nested scrolling for scrollable content.
*/
public class SheetBottomDialog {
/**
* Creates a {@link SlideDialog} that slides up from the bottom of the screen with a specified content view.
* The dialog supports drag-to-dismiss functionality, allowing the user to drag it downward to close it,
* with proper handling of nested scrolling for scrollable content (e.g., {@link ListView}).
* It includes side margins, a top spacer for drag interaction, and can be dismissed by touching outside.
*
* @param context The context used to create the dialog.
* @param contentView The {@link View} to be displayed inside the dialog, such as a {@link LinearLayout}
* containing a {@link ListView}, buttons, or other UI elements.
* @param animationDuration The duration of the slide-in and slide-out animations in milliseconds.
* @return A configured {@link SlideDialog} instance ready to be shown.
* @throws IllegalArgumentException If contentView is null.
*/
public static SlideDialog createSlideDialog(@NonNull Context context, @NonNull View contentView, int animationDuration) {
SlideDialog dialog = new SlideDialog(context);
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
dialog.setCanceledOnTouchOutside(true);
dialog.setCancelable(true);
// Create wrapper layout for side margins.
LinearLayout wrapperLayout = new LinearLayout(context);
wrapperLayout.setOrientation(LinearLayout.VERTICAL);
// Create drag container.
DraggableLinearLayout dragContainer = new DraggableLinearLayout(context, animationDuration);
dragContainer.setOrientation(LinearLayout.VERTICAL);
dragContainer.setDialog(dialog);
// Add top spacer.
View spacer = new View(context);
final int dip40 = dipToPixels(40);
LinearLayout.LayoutParams spacerParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, dip40);
spacer.setLayoutParams(spacerParams);
spacer.setClickable(true);
dragContainer.addView(spacer);
// Add content view.
ViewGroup parent = (ViewGroup) contentView.getParent();
if (parent != null) parent.removeView(contentView);
dragContainer.addView(contentView);
// Add drag container to wrapper layout.
wrapperLayout.addView(dragContainer);
dialog.setContentView(wrapperLayout);
// Configure dialog window.
Window window = dialog.getWindow();
if (window != null) {
Utils.setDialogWindowParameters(window, Gravity.BOTTOM, 0, 100, false);
}
// Set up animation on drag container.
dialog.setAnimView(dragContainer);
dialog.setAnimationDuration(animationDuration);
return dialog;
}
/**
* Creates a {@link DraggableLinearLayout} with a rounded background and a centered handle bar,
* styled for use as the main layout in a {@link SlideDialog}. The layout has vertical orientation,
* includes padding, and supports drag-to-dismiss functionality with proper handling of nested scrolling
* for scrollable content (e.g., {@link ListView}) or clickable elements (e.g., buttons, {@link android.widget.SeekBar}).
*
* @param context The context used to create the layout.
* @param backgroundColor The background color for the layout as an {@link Integer}, or null to use
* the default dialog background color.
* @return A configured {@link DraggableLinearLayout} with a handle bar and styled background.
*/
public static DraggableLinearLayout createMainLayout(@NonNull Context context, @Nullable Integer backgroundColor) {
// Preset size constants.
final int dip4 = dipToPixels(4); // Handle bar height.
final int dip8 = dipToPixels(8); // Dialog padding.
final int dip40 = dipToPixels(40); // Handle bar width.
DraggableLinearLayout mainLayout = new DraggableLinearLayout(context);
mainLayout.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
layoutParams.setMargins(dip8, 0, dip8, dip8);
mainLayout.setLayoutParams(layoutParams);
ShapeDrawable background = new ShapeDrawable(new RoundRectShape(
Utils.createCornerRadii(12), null, null));
int color = (backgroundColor != null) ? backgroundColor : Utils.getDialogBackgroundColor();
background.getPaint().setColor(color);
mainLayout.setBackground(background);
// Add handle bar.
LinearLayout handleContainer = new LinearLayout(context);
LinearLayout.LayoutParams containerParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
containerParams.setMargins(0, dip8, 0, 0);
handleContainer.setLayoutParams(containerParams);
handleContainer.setGravity(Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM);
View handleBar = new View(context);
ShapeDrawable handleBackground = new ShapeDrawable(new RoundRectShape(
Utils.createCornerRadii(4), null, null));
handleBackground.getPaint().setColor(Utils.adjustColorBrightness(color, 0.9f, 1.25f));
LinearLayout.LayoutParams handleParams = new LinearLayout.LayoutParams(dip40, dip4);
handleBar.setLayoutParams(handleParams);
handleBar.setBackground(handleBackground);
handleContainer.addView(handleBar);
mainLayout.addView(handleContainer);
return mainLayout;
}
/**
* A custom {@link LinearLayout} that provides drag-to-dismiss functionality for a {@link SlideDialog}.
* This layout intercepts touch events to allow dragging the dialog downward to dismiss it when the
* content cannot scroll upward. It ensures compatibility with scrollable content (e.g., {@link ListView},
* {@link ScrollView}) and clickable elements (e.g., buttons, {@link android.widget.SeekBar}) by prioritizing
* their touch events to prevent conflicts.
*
* <p>Dragging is enabled only after the dialog's slide-in animation completes. The dialog is dismissed
* if dragged beyond 50% of its height or with a downward fling velocity exceeding 800 px/s.</p>
*/
public static class DraggableLinearLayout extends LinearLayout {
private static final int MIN_FLING_VELOCITY = 800; // px/s
private static final float DISMISS_HEIGHT_FRACTION = 0.5f; // 50% of height.
private float initialTouchRawY; // Raw Y on ACTION_DOWN.
private float dragOffset; // Current drag translation.
private boolean isDragging;
private boolean isDragEnabled;
private final int animationDuration;
private final Scroller scroller;
private final VelocityTracker velocityTracker;
private final Runnable settleRunnable;
private SlideDialog dialog;
private float dismissThreshold;
/**
* Constructs a new {@link DraggableLinearLayout} with the specified context.
*/
public DraggableLinearLayout(@NonNull Context context) {
this(context, 0);
}
/**
* Constructs a new {@link DraggableLinearLayout} with the specified context and animation duration.
*
* @param context The context used to initialize the layout.
* @param animDuration The duration of the drag animation in milliseconds.
*/
public DraggableLinearLayout(@NonNull Context context, int animDuration) {
super(context);
scroller = new Scroller(context, new DecelerateInterpolator());
velocityTracker = VelocityTracker.obtain();
animationDuration = animDuration;
settleRunnable = this::runSettleAnimation;
setClickable(true);
// Enable drag only after slide-in animation finishes.
isDragEnabled = false;
postDelayed(() -> isDragEnabled = true, animationDuration + 50);
}
/**
* Sets the {@link SlideDialog} associated with this layout for dismissal.
*/
public void setDialog(@NonNull SlideDialog dialog) {
this.dialog = dialog;
}
/**
* Updates the dismissal threshold when the layout's size changes.
*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
dismissThreshold = h * DISMISS_HEIGHT_FRACTION;
}
/**
* Intercepts touch events to initiate dragging when the content cannot scroll upward and the
* touch movement exceeds the system's touch slop.
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (!isDragEnabled) return false;
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
initialTouchRawY = ev.getRawY();
isDragging = false;
scroller.forceFinished(true);
removeCallbacks(settleRunnable);
velocityTracker.clear();
velocityTracker.addMovement(ev);
dragOffset = getTranslationY();
break;
case MotionEvent.ACTION_MOVE:
float dy = ev.getRawY() - initialTouchRawY;
if (dy > ViewConfiguration.get(getContext()).getScaledTouchSlop()
&& !canChildScrollUp()) {
isDragging = true;
return true; // Intercept touches for drag.
}
break;
}
return false;
}
/**
* Handles touch events to perform dragging or trigger dismissal/return animations based on
* drag distance or fling velocity.
*/
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent ev) {
if (!isDragEnabled) return super.onTouchEvent(ev);
velocityTracker.addMovement(ev);
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_MOVE:
if (isDragging) {
float deltaY = ev.getRawY() - initialTouchRawY;
dragOffset = Math.max(0, deltaY); // Prevent upward drag.
setTranslationY(dragOffset); // 1:1 following finger.
}
return true;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
velocityTracker.computeCurrentVelocity(1000);
float velocityY = velocityTracker.getYVelocity();
if (dragOffset > dismissThreshold || velocityY > MIN_FLING_VELOCITY) {
startDismissAnimation();
} else {
startReturnAnimation();
}
isDragging = false;
return true;
}
// Consume the touch event to prevent focus changes on child views.
return true;
}
/**
* Starts an animation to dismiss the dialog by sliding it downward.
*/
private void startDismissAnimation() {
scroller.startScroll(0, (int) dragOffset,
0, getHeight() - (int) dragOffset, animationDuration);
post(settleRunnable);
}
/**
* Starts an animation to return the dialog to its original position.
*/
private void startReturnAnimation() {
scroller.startScroll(0, (int) dragOffset,
0, -(int) dragOffset, animationDuration);
post(settleRunnable);
}
/**
* Runs the settle animation, updating the layout's translation until the animation completes.
* Dismisses the dialog if the drag offset reaches the view's height.
*/
private void runSettleAnimation() {
if (scroller.computeScrollOffset()) {
dragOffset = scroller.getCurrY();
setTranslationY(dragOffset);
if (dragOffset >= getHeight() && dialog != null) {
dialog.dismiss();
scroller.forceFinished(true);
} else {
post(settleRunnable);
}
} else {
dragOffset = getTranslationY();
}
}
/**
* Checks if any child view can scroll upward, preventing drag if scrolling is possible.
*
* @return True if a child can scroll upward, false otherwise.
*/
private boolean canChildScrollUp() {
View target = findScrollableChild(this);
return target != null && target.canScrollVertically(-1);
}
/**
* Recursively searches for a scrollable child view within the given view group.
*
* @param group The view group to search.
* @return The scrollable child view, or null if none found.
*/
private View findScrollableChild(ViewGroup group) {
for (int i = 0; i < group.getChildCount(); i++) {
View child = group.getChildAt(i);
if (child.canScrollVertically(-1)) return child;
if (child instanceof ViewGroup) {
View scroll = findScrollableChild((ViewGroup) child);
if (scroll != null) return scroll;
}
}
return null;
}
}
/**
* A custom dialog that slides up from the bottom of the screen with animation. It supports
* drag-to-dismiss functionality and ensures smooth dismissal animations without overlapping
* dismiss calls. The dialog animates a specified view during show and dismiss operations.
*/
public static class SlideDialog extends Dialog {
private View animView;
private boolean isDismissing = false;
private int duration;
private final int screenHeight;
/**
* Constructs a new {@link SlideDialog} with the specified context.
*/
public SlideDialog(@NonNull Context context) {
super(context);
screenHeight = context.getResources().getDisplayMetrics().heightPixels;
}
/**
* Sets the view to animate during show and dismiss operations.
*/
public void setAnimView(@NonNull View view) {
this.animView = view;
}
/**
* Sets the duration of the slide-in and slide-out animations.
*/
public void setAnimationDuration(int duration) {
this.duration = duration;
}
/**
* Displays the dialog with a slide-up animation for the animated view, if set.
*/
@Override
public void show() {
super.show();
if (animView == null) return;
animView.setTranslationY(screenHeight);
animView.animate()
.translationY(0)
.setDuration(duration)
.setListener(null)
.start();
}
/**
* Cancels the dialog, triggering a dismissal animation.
*/
@Override
public void cancel() {
dismiss();
}
/**
* Dismisses the dialog with a slide-down animation for the animated view, if set.
* Ensures that dismissal is not triggered multiple times concurrently.
*/
@Override
public void dismiss() {
if (isDismissing) return;
isDismissing = true;
Window window = getWindow();
if (window == null) {
super.dismiss();
isDismissing = false;
return;
}
WindowManager.LayoutParams params = window.getAttributes();
float startDim = params != null ? params.dimAmount : 0f;
// Animate dimming effect.
ValueAnimator dimAnimator = ValueAnimator.ofFloat(startDim, 0f);
dimAnimator.setDuration(duration);
dimAnimator.addUpdateListener(animation -> {
if (params != null) {
params.dimAmount = (float) animation.getAnimatedValue();
window.setAttributes(params);
}
});
if (animView == null) {
dimAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
SlideDialog.super.dismiss();
isDismissing = false;
}
});
dimAnimator.start();
return;
}
dimAnimator.start();
animView.animate()
.translationY(screenHeight)
.setDuration(duration)
.setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
SlideDialog.super.dismiss();
isDismissing = false;
}
})
.start();
}
}
}

View File

@@ -2,4 +2,5 @@ dependencies {
compileOnly(project(":extensions:shared:library"))
compileOnly(project(":extensions:syncforreddit:stub"))
compileOnly(libs.annotation)
compileOnly(libs.okhttp)
}

View File

@@ -0,0 +1,22 @@
package app.revanced.extension.syncforreddit;
import app.revanced.extension.shared.fixes.redgifs.BaseFixRedgifsApiPatch;
import okhttp3.OkHttpClient;
/**
* @noinspection unused
*/
public class FixRedgifsApiPatch extends BaseFixRedgifsApiPatch {
static {
INSTANCE = new FixRedgifsApiPatch();
}
public String getDefaultUserAgent() {
// To be filled in by patch
return "";
}
public static OkHttpClient install(OkHttpClient.Builder builder) {
return builder.addInterceptor(INSTANCE).build();
}
}

View File

@@ -7,29 +7,29 @@ import android.preference.PreferenceFragment;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.tiktok.settings.preference.ReVancedPreferenceFragment;
import app.revanced.extension.tiktok.settings.preference.TikTokPreferenceFragment;
import com.bytedance.ies.ugc.aweme.commercialize.compliance.personalization.AdPersonalizationActivity;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
/**
* Hooks AdPersonalizationActivity.
* <p>
* This class is responsible for injecting our own fragment by replacing the AdPersonalizationActivity.
*
* @noinspection unused
* Hooks AdPersonalizationActivity to inject a custom {@link TikTokPreferenceFragment}.
*/
public class AdPersonalizationActivityHook {
@SuppressWarnings({"deprecation", "NewApi", "unused"})
public class TikTokActivityHook {
public static Object createSettingsEntry(String entryClazzName, String entryInfoClazzName) {
try {
Class<?> entryClazz = Class.forName(entryClazzName);
Class<?> entryInfoClazz = Class.forName(entryInfoClazzName);
Constructor<?> entryConstructor = entryClazz.getConstructor(entryInfoClazz);
Constructor<?> entryInfoConstructor = entryInfoClazz.getDeclaredConstructors()[0];
Object buttonInfo = entryInfoConstructor.newInstance("ReVanced settings", null, (View.OnClickListener) view -> startSettingsActivity(), "revanced");
Object buttonInfo = entryInfoConstructor.newInstance(
"ReVanced settings", null, (View.OnClickListener) view -> startSettingsActivity(), "revanced");
return entryConstructor.newInstance(buttonInfo);
} catch (ClassNotFoundException | NoSuchMethodException | InvocationTargetException | IllegalAccessException |
InstantiationException e) {
@@ -62,7 +62,7 @@ public class AdPersonalizationActivityHook {
linearLayout.addView(fragment);
base.setContentView(linearLayout);
PreferenceFragment preferenceFragment = new ReVancedPreferenceFragment();
PreferenceFragment preferenceFragment = new TikTokPreferenceFragment();
base.getFragmentManager().beginTransaction().replace(fragmentId, preferenceFragment).commit();
return true;

View File

@@ -121,7 +121,7 @@ public class DownloadPathPreference extends DialogPreference {
}
private int findIndexOf(String str) {
for (int i = 0; i < entryValues.length; i++) {
for (int i = 0, length = entryValues.length; i < length; i++) {
if (str.equals(entryValues[i])) return i;
}
return -1;

View File

@@ -15,7 +15,7 @@ public class ReVancedTikTokAboutPreference extends ReVancedAboutPreference {
/**
* Because resources cannot be added to TikTok,
* these strings are copied from the shared strings.xml file.
*
* <p>
* Changes here must also be made in strings.xml
*/
private final Map<String, String> aboutStrings = Map.of(

View File

@@ -14,7 +14,7 @@ import app.revanced.extension.tiktok.settings.preference.categories.SimSpoofPref
* Preference fragment for ReVanced settings
*/
@SuppressWarnings("deprecation")
public class ReVancedPreferenceFragment extends AbstractPreferenceFragment {
public class TikTokPreferenceFragment extends AbstractPreferenceFragment {
@Override
protected void syncSettingWithPreference(@NonNull Preference pref,

View File

@@ -1,11 +1,16 @@
package app.revanced.extension.twitch.settings;
import static app.revanced.extension.twitch.Utils.getStringId;
import android.content.Intent;
import android.os.Bundle;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.twitch.settings.preference.ReVancedPreferenceFragment;
import app.revanced.extension.twitch.settings.preference.TwitchPreferenceFragment;
import tv.twitch.android.feature.settings.menu.SettingsMenuGroup;
import tv.twitch.android.settings.SettingsActivity;
@@ -13,17 +18,15 @@ import java.util.ArrayList;
import java.util.List;
/**
* Hooks AppCompatActivity.
* <p>
* This class is responsible for injecting our own fragment by replacing the AppCompatActivity.
* @noinspection unused
* Hooks AppCompatActivity to inject a custom {@link TwitchPreferenceFragment}.
*/
public class AppCompatActivityHook {
@SuppressWarnings({"deprecation", "NewApi", "unused"})
public class TwitchActivityHook {
private static final int REVANCED_SETTINGS_MENU_ITEM_ID = 0x7;
private static final String EXTRA_REVANCED_SETTINGS = "app.revanced.twitch.settings";
/**
* Launches SettingsActivity and show ReVanced settings
* Launches SettingsActivity and show ReVanced settings.
*/
public static void startSettingsActivity() {
Logger.printDebug(() -> "Launching ReVanced settings");
@@ -41,27 +44,27 @@ public class AppCompatActivityHook {
}
/**
* Helper for easy access in smali
* @return Returns string resource id
* Helper for easy access in smali.
* @return Returns string resource id.
*/
public static int getReVancedSettingsString() {
return app.revanced.extension.twitch.Utils.getStringId("revanced_settings");
return getStringId("revanced_settings");
}
/**
* Intercepts settings menu group list creation in SettingsMenuPresenter$Event.MenuGroupsUpdated
* @return Returns a modified list of menu groups
* Intercepts settings menu group list creation in SettingsMenuPresenter$Event.MenuGroupsUpdated.
* @return Returns a modified list of menu groups.
*/
public static List<SettingsMenuGroup> handleSettingMenuCreation(List<SettingsMenuGroup> settingGroups, Object revancedEntry) {
List<SettingsMenuGroup> groups = new ArrayList<>(settingGroups);
if (groups.isEmpty()) {
// Create new menu group if none exist yet
// Create new menu group if none exist yet.
List<Object> items = new ArrayList<>();
items.add(revancedEntry);
groups.add(new SettingsMenuGroup(items));
} else {
// Add to last menu group
// Add to last menu group.
int groupIdx = groups.size() - 1;
List<Object> items = new ArrayList<>(groups.remove(groupIdx).getSettingsMenuItems());
items.add(revancedEntry);
@@ -73,8 +76,8 @@ public class AppCompatActivityHook {
}
/**
* Intercepts settings menu group onclick events
* @return Returns true if handled, otherwise false
* Intercepts settings menu group onclick events.
* @return Returns true if handled, otherwise false.
*/
@SuppressWarnings("rawtypes")
public static boolean handleSettingMenuOnClick(Enum item) {
@@ -88,20 +91,20 @@ public class AppCompatActivityHook {
}
/**
* Intercepts fragment loading in SettingsActivity.onCreate
* @return Returns true if the revanced settings have been requested by the user, otherwise false
* Intercepts fragment loading in SettingsActivity.onCreate.
* @return Returns true if the ReVanced settings have been requested by the user, otherwise false.
*/
public static boolean handleSettingsCreation(androidx.appcompat.app.AppCompatActivity base) {
public static boolean handleSettingsCreation(AppCompatActivity base) {
if (!base.getIntent().getBooleanExtra(EXTRA_REVANCED_SETTINGS, false)) {
Logger.printDebug(() -> "Revanced settings not requested");
return false; // User wants to enter another settings fragment
return false; // User wants to enter another settings fragment.
}
Logger.printDebug(() -> "ReVanced settings requested");
ReVancedPreferenceFragment fragment = new ReVancedPreferenceFragment();
TwitchPreferenceFragment fragment = new TwitchPreferenceFragment();
ActionBar supportActionBar = base.getSupportActionBar();
if (supportActionBar != null)
supportActionBar.setTitle(app.revanced.extension.twitch.Utils.getStringId("revanced_settings"));
supportActionBar.setTitle(getStringId("revanced_settings"));
base.getFragmentManager()
.beginTransaction()

View File

@@ -7,6 +7,7 @@ import android.util.AttributeSet;
import android.view.View;
import android.widget.TextView;
@SuppressWarnings({"deprecation", "unused"})
public class CustomPreferenceCategory extends PreferenceCategory {
public CustomPreferenceCategory(Context context, AttributeSet attrs) {
super(context, attrs);

View File

@@ -5,9 +5,9 @@ import app.revanced.extension.shared.settings.preference.AbstractPreferenceFragm
import app.revanced.extension.twitch.settings.Settings;
/**
* Preference fragment for ReVanced settings
* Preference fragment for ReVanced settings.
*/
public class ReVancedPreferenceFragment extends AbstractPreferenceFragment {
public class TwitchPreferenceFragment extends AbstractPreferenceFragment {
@Override
protected void initialize() {

View File

@@ -19,6 +19,7 @@ import java.net.HttpURLConnection;
import java.net.URL;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
@@ -65,6 +66,17 @@ public final class AlternativeThumbnailsPatch {
public boolean isAvailable() {
return usingDeArrowAnywhere();
}
@Override
public List<Setting<?>> getParentSettings() {
return List.of(
ALT_THUMBNAIL_HOME,
ALT_THUMBNAIL_SUBSCRIPTIONS,
ALT_THUMBNAIL_LIBRARY,
ALT_THUMBNAIL_PLAYER,
ALT_THUMBNAIL_SEARCH
);
}
}
public static final class StillImagesAvailability implements Setting.Availability {
@@ -80,6 +92,17 @@ public final class AlternativeThumbnailsPatch {
public boolean isAvailable() {
return usingStillImagesAnywhere();
}
@Override
public List<Setting<?>> getParentSettings() {
return List.of(
ALT_THUMBNAIL_HOME,
ALT_THUMBNAIL_SUBSCRIPTIONS,
ALT_THUMBNAIL_LIBRARY,
ALT_THUMBNAIL_PLAYER,
ALT_THUMBNAIL_SEARCH
);
}
}
public enum ThumbnailOption {
@@ -451,29 +474,29 @@ public final class AlternativeThumbnailsPatch {
}
final boolean useFastQuality = Settings.ALT_THUMBNAIL_STILLS_FAST.get();
switch (quality) {
case SDDEFAULT:
// SD alt images have somewhat worse quality with washed out color and poor contrast.
// But the 720 images look much better and don't suffer from these issues.
// For unknown reasons, the 720 thumbnails are used only for the home feed,
// while SD is used for the search and subscription feed
// (even though search and subscriptions use the exact same layout as the home feed).
// Of note, this image quality issue only appears with the alt thumbnail images,
// and the regular thumbnails have identical color/contrast quality for all sizes.
// Fix this by falling thru and upgrading SD to 720.
case HQ720:
return switch (quality) {
// SD alt images have somewhat worse quality with washed out color and poor contrast.
// But the 720 images look much better and don't suffer from these issues.
// For unknown reasons, the 720 thumbnails are used only for the home feed,
// while SD is used for the search and subscription feed
// (even though search and subscriptions use the exact same layout as the home feed).
// Of note, this image quality issue only appears with the alt thumbnail images,
// and the regular thumbnails have identical color/contrast quality for all sizes.
// Fix this by falling thru and upgrading SD to 720.
case SDDEFAULT, HQ720 -> { // SD is max resolution for fast alt images.
if (useFastQuality) {
return SDDEFAULT; // SD is max resolution for fast alt images.
yield SDDEFAULT;
}
return HQ720;
case MAXRESDEFAULT:
yield HQ720;
}
case MAXRESDEFAULT -> {
if (useFastQuality) {
return SDDEFAULT;
yield SDDEFAULT;
}
return MAXRESDEFAULT;
default:
return quality;
}
yield MAXRESDEFAULT;
}
default -> quality;
};
}
final String originalName;

View File

@@ -12,6 +12,8 @@ import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.youtube.settings.Settings;
import java.util.List;
@SuppressWarnings("unused")
public final class ChangeStartPagePatch {
@@ -87,6 +89,11 @@ public final class ChangeStartPagePatch {
public boolean isAvailable() {
return Settings.CHANGE_START_PAGE.get() != StartPage.DEFAULT;
}
@Override
public List<Setting<?>> getParentSettings() {
return List.of(Settings.CHANGE_START_PAGE);
}
}
/**

View File

@@ -13,6 +13,7 @@ import java.net.UnknownHostException;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.ui.CustomDialog;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
@@ -68,7 +69,7 @@ public class CheckWatchHistoryDomainNameResolutionPatch {
Utils.runOnMainThread(() -> {
try {
// Create the custom dialog.
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context,
str("revanced_check_watch_history_domain_name_dialog_title"), // Title.
Html.fromHtml(str("revanced_check_watch_history_domain_name_dialog_message")), // Message (HTML).

View File

@@ -27,11 +27,11 @@ public final class DownloadsPatch {
/**
* Injection point.
*
* <p>
* Called from the in app download hook,
* for both the player action button (below the video)
* and the 'Download video' flyout option for feed videos.
*
* <p>
* Appears to always be called from the main thread.
*/
public static boolean inAppDownloadButtonOnClick(String videoId) {

View File

@@ -1,7 +1,7 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.shared.settings.AppLanguage;
import app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch;
import app.revanced.extension.youtube.settings.Settings;
@@ -11,16 +11,21 @@ public class ForceOriginalAudioPatch {
private static final String DEFAULT_AUDIO_TRACKS_SUFFIX = ".4";
/**
* If the conditions to use this patch were present when the app launched.
* Injection point.
*/
public static boolean PATCH_AVAILABLE = SpoofVideoStreamsPatch.notSpoofingToAndroid();
public static final class ForceOriginalAudioAvailability implements Setting.Availability {
@Override
public boolean isAvailable() {
// Check conditions of launch and now. Otherwise if spoofing is changed
// without a restart the setting will show as available when it's not.
return PATCH_AVAILABLE && SpoofVideoStreamsPatch.notSpoofingToAndroid();
public static void setPreferredLanguage() {
if (Settings.FORCE_ORIGINAL_AUDIO.get()
&& SpoofVideoStreamsPatch.spoofingToClientWithNoMultiAudioStreams()) {
// If client spoofing does not use authentication and lacks multi-audio streams,
// then can use any language code for the request and if that requested language is
// not available YT uses the original audio language. Authenticated requests ignore
// the language code and always use the account language. Use a language that is
// not auto-dubbed by YouTube: https://support.google.com/youtube/answer/15569972
// but the language is also supported natively by the Meta Quest device that
// Android VR is spoofing.
AppLanguage override = AppLanguage.SV;
Logger.printDebug(() -> "Setting language override: " + override);
SpoofVideoStreamsPatch.setLanguageOverride(override);
}
}

View File

@@ -1,5 +1,7 @@
package app.revanced.extension.youtube.patches;
import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
@@ -37,11 +39,11 @@ public final class HidePlayerOverlayButtonsPatch {
private static final boolean HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS_ENABLED
= Settings.HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS.get();
private static final int PLAYER_CONTROL_PREVIOUS_BUTTON_TOUCH_AREA_ID =
Utils.getResourceIdentifier("player_control_previous_button_touch_area", "id");
private static final int PLAYER_CONTROL_PREVIOUS_BUTTON_TOUCH_AREA_ID = getResourceIdentifierOrThrow(
"player_control_previous_button_touch_area", "id");
private static final int PLAYER_CONTROL_NEXT_BUTTON_TOUCH_AREA_ID =
Utils.getResourceIdentifier("player_control_next_button_touch_area", "id");
private static final int PLAYER_CONTROL_NEXT_BUTTON_TOUCH_AREA_ID = getResourceIdentifierOrThrow(
"player_control_next_button_touch_area", "id");
/**
* Injection point.

View File

@@ -12,12 +12,14 @@ import android.widget.TextView;
import androidx.annotation.Nullable;
import java.util.List;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings({"unused", "SpellCheckingInspection"})
@SuppressWarnings("SpellCheckingInspection")
public final class MiniplayerPatch {
/**
@@ -173,6 +175,14 @@ public final class MiniplayerPatch {
public boolean isAvailable() {
return Settings.MINIPLAYER_TYPE.get().isModern() && Settings.MINIPLAYER_DRAG_AND_DROP.get();
}
@Override
public List<Setting<?>> getParentSettings() {
return List.of(
Settings.MINIPLAYER_TYPE,
Settings.MINIPLAYER_DRAG_AND_DROP
);
}
}
public static final class MiniplayerHideOverlayButtonsAvailability implements Setting.Availability {
@@ -185,11 +195,59 @@ public final class MiniplayerPatch {
&& !Settings.MINIPLAYER_DOUBLE_TAP_ACTION.get() && !Settings.MINIPLAYER_DRAG_AND_DROP.get())
|| (IS_19_29_OR_GREATER && type == MODERN_3);
}
@Override
public List<Setting<?>> getParentSettings() {
return List.of(
Settings.MINIPLAYER_TYPE,
Settings.MINIPLAYER_DOUBLE_TAP_ACTION,
Settings.MINIPLAYER_DRAG_AND_DROP
);
}
}
public static final class MiniplayerAnyModernAvailability implements Setting.Availability {
@Override
public boolean isAvailable() {
MiniplayerType type = Settings.MINIPLAYER_TYPE.get();
return type == MODERN_1 || type == MODERN_2 || type == MODERN_3 || type == MODERN_4;
}
@Override
public List<Setting<?>> getParentSettings() {
return List.of(Settings.MINIPLAYER_TYPE);
}
}
public static final class MiniplayerHideSubtextsAvailability implements Setting.Availability {
@Override
public boolean isAvailable() {
MiniplayerType type = Settings.MINIPLAYER_TYPE.get();
return type == MODERN_3 || type == MODERN_4;
}
@Override
public List<Setting<?>> getParentSettings() {
return List.of(Settings.MINIPLAYER_TYPE);
}
}
public static final class MiniplayerHideRewindOrOverlayOpacityAvailability implements Setting.Availability {
@Override
public boolean isAvailable() {
MiniplayerType type = Settings.MINIPLAYER_TYPE.get();
return type == MODERN_1;
}
@Override
public List<Setting<?>> getParentSettings() {
return List.of(Settings.MINIPLAYER_TYPE);
}
}
/**
* Injection point.
*
* <p>
* Enables a handler that immediately closes the miniplayer when the video is minimized,
* effectively disabling the miniplayer.
*/
@@ -378,4 +436,4 @@ public final class MiniplayerPatch {
Logger.printException(() -> "playerOverlayGroupCreated failure", ex);
}
}
}
}

View File

@@ -1,17 +0,0 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class RemoveTrackingQueryParameterPatch {
private static final String NEW_TRACKING_PARAMETER_REGEX = ".si=.+";
private static final String OLD_TRACKING_PARAMETER_REGEX = ".feature=.+";
public static String sanitize(String url) {
if (!Settings.REMOVE_TRACKING_QUERY_PARAMETER.get()) return url;
return url
.replaceAll(NEW_TRACKING_PARAMETER_REGEX, "")
.replaceAll(OLD_TRACKING_PARAMETER_REGEX, "");
}
}

View File

@@ -3,6 +3,8 @@ package app.revanced.extension.youtube.patches;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.youtube.settings.Settings;
import java.util.List;
@SuppressWarnings("unused")
public class SeekbarThumbnailsPatch {
@@ -11,6 +13,11 @@ public class SeekbarThumbnailsPatch {
public boolean isAvailable() {
return VersionCheckPatch.IS_19_17_OR_GREATER || !Settings.RESTORE_OLD_SEEKBAR_THUMBNAILS.get();
}
@Override
public List<Setting<?>> getParentSettings() {
return List.of(Settings.RESTORE_OLD_SEEKBAR_THUMBNAILS);
}
}
private static final boolean SEEKBAR_THUMBNAILS_HIGH_QUALITY_ENABLED

View File

@@ -513,6 +513,9 @@ public final class VideoInformation {
Logger.printDebug(() -> "VideoQualities: " + Arrays.toString(currentQualities));
}
// On extremely slow internet connections the index can initially be -1
originalQualityIndex = Math.max(0, originalQualityIndex);
VideoQuality updatedCurrentQuality = qualities[originalQualityIndex];
if (updatedCurrentQuality.patch_getResolution() != AUTOMATIC_VIDEO_QUALITY_VALUE
&& (currentQuality == null || currentQuality != updatedCurrentQuality)) {

View File

@@ -14,6 +14,7 @@ import android.view.View;
import android.widget.LinearLayout;
import android.widget.TextView;
import app.revanced.extension.shared.ui.CustomDialog;
import org.json.JSONArray;
import java.io.IOException;
@@ -125,7 +126,7 @@ public final class AnnouncementsPatch {
Utils.runOnMainThread(() -> {
// Create the custom dialog and show the announcement.
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context,
finalTitle, // Title.
finalMessage, // Message.

View File

@@ -10,7 +10,7 @@ import java.util.List;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.StringTrieSearch;
import app.revanced.extension.shared.StringTrieSearch;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")

View File

@@ -7,6 +7,10 @@ final class ButtonsFilter extends Filter {
private static final String COMPACT_CHANNEL_BAR_PATH_PREFIX = "compact_channel_bar.eml";
private static final String VIDEO_ACTION_BAR_PATH_PREFIX = "video_action_bar.eml";
private static final String VIDEO_ACTION_BAR_PATH = "video_action_bar.eml";
/**
* Video bar path when the video information is collapsed. Seems to shown only with 20.14+
*/
private static final String COMPACTIFY_VIDEO_ACTION_BAR_PATH = "compactify_video_action_bar.eml";
private static final String ANIMATED_VECTOR_TYPE_PATH = "AnimatedVectorType";
private final StringFilterGroup likeSubscribeGlow;
@@ -74,10 +78,18 @@ final class ButtonsFilter extends Filter {
Settings.HIDE_ASK_BUTTON,
"yt_fill_spark"
),
new ByteArrayFilterGroup(
Settings.HIDE_SHOP_BUTTON,
"yt_outline_bag"
),
new ByteArrayFilterGroup(
Settings.HIDE_STOP_ADS_BUTTON,
"yt_outline_slash_circle_left"
),
new ByteArrayFilterGroup(
Settings.HIDE_COMMENTS_BUTTON,
"yt_outline_message_bubble_right"
),
// Check for clip button both here and using a path filter,
// as there's a chance the path is a generic action button and won't contain 'clip_button'
new ByteArrayFilterGroup(
@@ -120,9 +132,8 @@ final class ButtonsFilter extends Filter {
}
if (matchedGroup == bufferFilterPathGroup) {
// Make sure the current path is the right one
// to avoid false positives.
return path.startsWith(VIDEO_ACTION_BAR_PATH)
// Make sure the current path is the right one to avoid false positives.
return (path.startsWith(VIDEO_ACTION_BAR_PATH) || path.startsWith(COMPACTIFY_VIDEO_ACTION_BAR_PATH))
&& bufferButtonsGroupList.check(buffer).isFiltered();
}

View File

@@ -14,7 +14,7 @@ import java.util.regex.Pattern;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.ByteTrieSearch;
import app.revanced.extension.shared.ByteTrieSearch;
import app.revanced.extension.youtube.settings.Settings;
/**

View File

@@ -1,6 +1,6 @@
package app.revanced.extension.youtube.patches.components;
import app.revanced.extension.youtube.StringTrieSearch;
import app.revanced.extension.shared.StringTrieSearch;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.PlayerType;

View File

@@ -4,7 +4,7 @@ import androidx.annotation.NonNull;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.settings.BooleanSetting;
import app.revanced.extension.youtube.ByteTrieSearch;
import app.revanced.extension.shared.ByteTrieSearch;
abstract class FilterGroup<T> {
final static class FilterGroupResult {

View File

@@ -5,9 +5,9 @@ import androidx.annotation.NonNull;
import java.util.*;
import java.util.function.Consumer;
import app.revanced.extension.youtube.ByteTrieSearch;
import app.revanced.extension.youtube.StringTrieSearch;
import app.revanced.extension.youtube.TrieSearch;
import app.revanced.extension.shared.ByteTrieSearch;
import app.revanced.extension.shared.StringTrieSearch;
import app.revanced.extension.shared.TrieSearch;
abstract class FilterGroupList<V, T extends FilterGroup<V>> implements Iterable<T> {

View File

@@ -14,9 +14,9 @@ import java.util.concurrent.atomic.AtomicReference;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.ByteTrieSearch;
import app.revanced.extension.youtube.StringTrieSearch;
import app.revanced.extension.youtube.TrieSearch;
import app.revanced.extension.shared.ByteTrieSearch;
import app.revanced.extension.shared.StringTrieSearch;
import app.revanced.extension.shared.TrieSearch;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.NavigationBar;
import app.revanced.extension.youtube.shared.PlayerType;

View File

@@ -10,7 +10,7 @@ import androidx.annotation.Nullable;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.StringTrieSearch;
import app.revanced.extension.shared.StringTrieSearch;
import app.revanced.extension.youtube.patches.ChangeHeaderPatch;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.NavigationBar;
@@ -194,7 +194,7 @@ public final class LayoutComponentsFilter extends Filter {
// Playable horizontal shelf header.
playablesBuffer = new ByteArrayFilterGroup(
Settings.HIDE_PLAYABLES,
"mini_game"
"FEmini_app_destination"
);
final var quickActions = new StringFilterGroup(

Some files were not shown because too many files have changed in this diff Show More