Compare commits

..

339 Commits

Author SHA1 Message Date
Pun Butrach
5d437b08dd docs: Use American spelling (#6233) 2025-12-14 16:38:55 +01:00
f1re4xx
018d176914 fix(Lightroom): Add Disable version check patch to fix opening the app (#6315)
Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
2025-12-14 16:38:26 +01:00
semantic-release-bot
9a77beea8a chore: Release v5.47.0-dev.15 [skip ci]
# [5.47.0-dev.15](https://github.com/ReVanced/revanced-patches/compare/v5.47.0-dev.14...v5.47.0-dev.15) (2025-12-13)

### Bug Fixes

* **YouTube - Hide layout components:** Fix "Hide Subscribe button" in channel page not working ([#6363](https://github.com/ReVanced/revanced-patches/issues/6363)) ([ded8370](ded8370207))
2025-12-13 20:26:05 +00:00
ILoveOpenSourceApplications
ded8370207 fix(YouTube - Hide layout components): Fix "Hide Subscribe button" in channel page not working (#6363) 2025-12-13 21:22:35 +01:00
semantic-release-bot
4d1104fc32 chore: Release v5.47.0-dev.14 [skip ci]
# [5.47.0-dev.14](https://github.com/ReVanced/revanced-patches/compare/v5.47.0-dev.13...v5.47.0-dev.14) (2025-12-13)

### Bug Fixes

* **Spotify:** Make patches work with latest versions again ([#6359](https://github.com/ReVanced/revanced-patches/issues/6359)) ([34830ba](34830ba63b))
2025-12-13 08:52:03 +00:00
Cilly Leang
34830ba63b fix(Spotify): Make patches work with latest versions again (#6359) 2025-12-13 09:48:39 +01:00
github-actions[bot]
7a6894d809 chore: Sync translations (#6344)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-12-10 03:58:45 +01:00
semantic-release-bot
144e6e2694 chore: Release v5.47.0-dev.13 [skip ci]
# [5.47.0-dev.13](https://github.com/ReVanced/revanced-patches/compare/v5.47.0-dev.12...v5.47.0-dev.13) (2025-12-10)

### Features

* **Peacock TV:** Add `Hide ads` patch ([#6348](https://github.com/ReVanced/revanced-patches/issues/6348)) ([847ee18](847ee189a9))
2025-12-10 02:58:06 +00:00
g9q
847ee189a9 feat(Peacock TV): Add Hide ads patch (#6348) 2025-12-10 03:55:08 +01:00
semantic-release-bot
dc813fe617 chore: Release v5.47.0-dev.12 [skip ci]
# [5.47.0-dev.12](https://github.com/ReVanced/revanced-patches/compare/v5.47.0-dev.11...v5.47.0-dev.12) (2025-12-08)

### Features

* **YouTube - Hide layout components:** Add "Hide Join button" and "Hide Subscribe button" options for channel page ([#6345](https://github.com/ReVanced/revanced-patches/issues/6345)) ([02831a6](02831a6069))
2025-12-08 21:14:39 +00:00
ILoveOpenSourceApplications
02831a6069 feat(YouTube - Hide layout components): Add "Hide Join button" and "Hide Subscribe button" options for channel page (#6345) 2025-12-08 22:10:35 +01:00
semantic-release-bot
5228fd4b58 chore: Release v5.47.0-dev.11 [skip ci]
# [5.47.0-dev.11](https://github.com/ReVanced/revanced-patches/compare/v5.47.0-dev.10...v5.47.0-dev.11) (2025-12-08)

### Features

* **Disney+:** Add `Skip ads` patch ([#6343](https://github.com/ReVanced/revanced-patches/issues/6343)) ([6bd7dca](6bd7dca75b))
2025-12-08 13:51:15 +00:00
g9q
6bd7dca75b feat(Disney+): Add Skip ads patch (#6343)
Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
2025-12-08 14:45:41 +01:00
semantic-release-bot
22ed7bfbb3 chore: Release v5.47.0-dev.10 [skip ci]
# [5.47.0-dev.10](https://github.com/ReVanced/revanced-patches/compare/v5.47.0-dev.9...v5.47.0-dev.10) (2025-12-08)

### Features

* **YouTube - Hide Shorts components:** Add "Hide auto-dubbed label" and "Hide live preview" options ([#6334](https://github.com/ReVanced/revanced-patches/issues/6334)) ([a7c220a](a7c220a4ae))
2025-12-08 12:57:23 +00:00
ILoveOpenSourceApplications
a7c220a4ae feat(YouTube - Hide Shorts components): Add "Hide auto-dubbed label" and "Hide live preview" options (#6334) 2025-12-08 13:51:57 +01:00
semantic-release-bot
d8ca4ee931 chore: Release v5.47.0-dev.9 [skip ci]
# [5.47.0-dev.9](https://github.com/ReVanced/revanced-patches/compare/v5.47.0-dev.8...v5.47.0-dev.9) (2025-12-08)

### Features

* **YouTube - Hide layout components:** Add "Hide cell divider", "Hide featured links", and "Hide featured videos" options ([#6335](https://github.com/ReVanced/revanced-patches/issues/6335)) ([a5d197b](a5d197b977))
2025-12-08 12:06:03 +00:00
ILoveOpenSourceApplications
a5d197b977 feat(YouTube - Hide layout components): Add "Hide cell divider", "Hide featured links", and "Hide featured videos" options (#6335)
Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
2025-12-08 13:02:22 +01:00
semantic-release-bot
a0ec4c07f7 chore: Release v5.47.0-dev.8 [skip ci]
# [5.47.0-dev.8](https://github.com/ReVanced/revanced-patches/compare/v5.47.0-dev.7...v5.47.0-dev.8) (2025-12-08)

### Features

* **Instagram:** Add `Disable Reels scrolling` patch ([#6317](https://github.com/ReVanced/revanced-patches/issues/6317)) ([0928dcd](0928dcd00d))
* **ProtonVPN:** Add `Remove delay` patch ([#6326](https://github.com/ReVanced/revanced-patches/issues/6326)) ([bbd8932](bbd8932b2e))
2025-12-08 11:36:43 +00:00
Alexey Gorbachev
0928dcd00d feat(Instagram): Add Disable Reels scrolling patch (#6317)
Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
2025-12-08 12:31:54 +01:00
Sylvain Finot
bbd8932b2e feat(ProtonVPN): Add Remove delay patch (#6326)
Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
2025-12-08 12:30:47 +01:00
semantic-release-bot
300b12f948 chore: Release v5.47.0-dev.7 [skip ci]
# [5.47.0-dev.7](https://github.com/ReVanced/revanced-patches/compare/v5.47.0-dev.6...v5.47.0-dev.7) (2025-12-03)

### Features

* **Spoof SIM provider:** Spoof additional TelephonyManager methods ([#6293](https://github.com/ReVanced/revanced-patches/issues/6293)) ([ac583d4](ac583d40d0))
2025-12-03 15:05:05 +00:00
rospino74
ac583d40d0 feat(Spoof SIM provider): Spoof additional TelephonyManager methods (#6293) 2025-12-03 16:01:08 +01:00
semantic-release-bot
c400188c38 chore: Release v5.47.0-dev.6 [skip ci]
# [5.47.0-dev.6](https://github.com/ReVanced/revanced-patches/compare/v5.47.0-dev.5...v5.47.0-dev.6) (2025-11-24)

### Features

* **Letterboxd:** Add `Hide ads` patch ([#6309](https://github.com/ReVanced/revanced-patches/issues/6309)) ([0af0ee9](0af0ee92c4))
2025-11-24 12:01:20 +00:00
Swakshan
0af0ee92c4 feat(Letterboxd): Add Hide ads patch (#6309)
Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
2025-11-24 12:56:55 +01:00
semantic-release-bot
fff29544b9 chore: Release v5.47.0-dev.5 [skip ci]
# [5.47.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v5.47.0-dev.4...v5.47.0-dev.5) (2025-11-13)

### Bug Fixes

* **YouTube - Hide player flyout menu items:** Allow hiding audio menu with 'Android No SDK' client type ([9495cf4](9495cf49ef))
2025-11-13 07:44:09 +00:00
LisoUseInAIKyrios
9495cf49ef fix(YouTube - Hide player flyout menu items): Allow hiding audio menu with 'Android No SDK' client type 2025-11-13 09:40:28 +02:00
semantic-release-bot
15675b5164 chore: Release v5.47.0-dev.4 [skip ci]
# [5.47.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.47.0-dev.3...v5.47.0-dev.4) (2025-11-12)

### Bug Fixes

* **YouTube - Sanitize sharing links:** Handle non hierarchical urls ([654d091](654d091e65))
2025-11-12 19:01:00 +00:00
LisoUseInAIKyrios
654d091e65 fix(YouTube - Sanitize sharing links): Handle non hierarchical urls 2025-11-12 20:55:32 +02:00
semantic-release-bot
98371be33c chore: Release v5.47.0-dev.3 [skip ci]
# [5.47.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.47.0-dev.2...v5.47.0-dev.3) (2025-11-12)

### Features

* **Instagram:** Add `Disable auto story flipping` patch ([#6262](https://github.com/ReVanced/revanced-patches/issues/6262)) ([2f0de15](2f0de15e67))
2025-11-12 07:46:04 +00:00
brosssh
2f0de15e67 feat(Instagram): Add Disable auto story flipping patch (#6262)
Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
2025-11-12 08:41:15 +01:00
semantic-release-bot
df160370e2 chore: Release v5.47.0-dev.2 [skip ci]
# [5.47.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.47.0-dev.1...v5.47.0-dev.2) (2025-11-12)

### Bug Fixes

* **Instagram - Disable signature check:** Change patch to default excluded ([#6283](https://github.com/ReVanced/revanced-patches/issues/6283)) ([bb745b5](bb745b555b))
2025-11-12 06:19:34 +00:00
LisoUseInAIKyrios
bb745b555b fix(Instagram - Disable signature check): Change patch to default excluded (#6283) 2025-11-12 08:14:16 +02:00
semantic-release-bot
8df9a46721 chore: Release v5.47.0-dev.1 [skip ci]
# [5.47.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.46.0...v5.47.0-dev.1) (2025-11-12)

### Features

* **Instagram:** Add `Anonymous story viewing` patch ([#6263](https://github.com/ReVanced/revanced-patches/issues/6263)) ([94ae84a](94ae84ad0f))
2025-11-12 05:32:16 +00:00
brosssh
94ae84ad0f feat(Instagram): Add Anonymous story viewing patch (#6263) 2025-11-12 07:29:13 +02:00
github-actions[bot]
4febb2e2e9 chore: Sync translations (#6280) 2025-11-12 07:28:43 +02:00
semantic-release-bot
b9bc7e3e58 chore: Release v5.46.0 [skip ci]
# [5.46.0](https://github.com/ReVanced/revanced-patches/compare/v5.45.0...v5.46.0) (2025-11-10)

### Bug Fixes

* **Duolingo - Disable ads:** Constrain patch to last working app target ([f238ae9](f238ae9895))
* **Instagram - Hide navigation buttons:** Constrain patch to last working app target ([e030e9c](e030e9c07a))
* **Spotify - Hide Create button:** Remove obsolete patch that is no longer needed ([#6252](https://github.com/ReVanced/revanced-patches/issues/6252)) ([59d85b2](59d85b28a7))
* **YouTube - Check watch history domain name resolution:** Fix false positive warning message if the internet connection fails halfway into the DNS check ([5726353](57263538c7))
* **YouTube - Hide layout components:** Fix "Hide Hype points" ([#6247](https://github.com/ReVanced/revanced-patches/issues/6247)) ([5821440](582144026d))
* **YouTube - Settings:** Add additional languages to ReVanced language preference ([d390b54](d390b54dab))
* **YouTube - Settings:** Resolve settings search crash when searching for specific words ([#6231](https://github.com/ReVanced/revanced-patches/issues/6231)) ([76dcfae](76dcfaefd8))

### Features

* **YouTube - Debugging:** Add setting to block experimental client flags ([#6196](https://github.com/ReVanced/revanced-patches/issues/6196)) ([2e9d695](2e9d6959c9))
* **YouTube - Hide layout components:** Add "Hide Hype points" ([#6230](https://github.com/ReVanced/revanced-patches/issues/6230)) ([a52c015](a52c0153b1))
* **YouTube - Hide layout components:** Add video description "Hide Featured content" and "Hide Subscribe button" ([#6253](https://github.com/ReVanced/revanced-patches/issues/6253)) ([da4cf94](da4cf94091))
* **YouTube - Hide player flyout menu items:** Add "Hide Listen with YouTube Music" ([#6232](https://github.com/ReVanced/revanced-patches/issues/6232)) ([858edbf](858edbf3e7))
* **YouTube Music:** Add `Change miniplayer color` patch ([#6259](https://github.com/ReVanced/revanced-patches/issues/6259)) ([ab808ae](ab808aeb77))
* **YouTube Music:** Add `Hide buttons` patch ([#6255](https://github.com/ReVanced/revanced-patches/issues/6255)) ([7a18ebc](7a18ebc7ab))
2025-11-10 10:06:40 +00:00
LisoUseInAIKyrios
9f3bb26cb9 chore: Merge branch dev to main (#6237) 2025-11-10 12:03:02 +02:00
github-actions[bot]
d64dfc2884 chore: Sync translations (#6276) 2025-11-10 12:00:41 +02:00
LisoUseInAIKyrios
a39ef1e0a4 refactor(YouTube Music - Custom branding): Resolve startup app crash when patching unsupported newer app versions 2025-11-10 11:23:09 +02:00
semantic-release-bot
1d8e977a43 chore: Release v5.46.0-dev.10 [skip ci]
# [5.46.0-dev.10](https://github.com/ReVanced/revanced-patches/compare/v5.46.0-dev.9...v5.46.0-dev.10) (2025-11-09)

### Features

* **YouTube - Hide layout components:** Add video description "Hide Featured content" and "Hide Subscribe button" ([#6253](https://github.com/ReVanced/revanced-patches/issues/6253)) ([da4cf94](da4cf94091))
2025-11-09 15:35:27 +00:00
ILoveOpenSourceApplications
da4cf94091 feat(YouTube - Hide layout components): Add video description "Hide Featured content" and "Hide Subscribe button" (#6253) 2025-11-09 17:30:07 +02:00
github-actions[bot]
d23fa5e3b7 chore: Sync translations (#6270) 2025-11-09 17:29:11 +02:00
semantic-release-bot
34d29abdfa chore: Release v5.46.0-dev.9 [skip ci]
# [5.46.0-dev.9](https://github.com/ReVanced/revanced-patches/compare/v5.46.0-dev.8...v5.46.0-dev.9) (2025-11-09)

### Features

* **YouTube Music:** Add `Change miniplayer color` patch ([#6259](https://github.com/ReVanced/revanced-patches/issues/6259)) ([ab808ae](ab808aeb77))
2025-11-09 07:43:26 +00:00
MarcaD
ab808aeb77 feat(YouTube Music): Add Change miniplayer color patch (#6259)
Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
2025-11-09 09:39:51 +02:00
github-actions[bot]
a6b07cceb1 chore: Sync translations (#6266) 2025-11-09 09:39:32 +02:00
semantic-release-bot
d291881215 chore: Release v5.46.0-dev.8 [skip ci]
# [5.46.0-dev.8](https://github.com/ReVanced/revanced-patches/compare/v5.46.0-dev.7...v5.46.0-dev.8) (2025-11-09)

### Features

* **YouTube Music:** Add `Hide buttons` patch ([#6255](https://github.com/ReVanced/revanced-patches/issues/6255)) ([7a18ebc](7a18ebc7ab))
2025-11-09 07:09:07 +00:00
MarcaD
7a18ebc7ab feat(YouTube Music): Add Hide buttons patch (#6255) 2025-11-09 09:05:31 +02:00
semantic-release-bot
475197af45 chore: Release v5.46.0-dev.7 [skip ci]
# [5.46.0-dev.7](https://github.com/ReVanced/revanced-patches/compare/v5.46.0-dev.6...v5.46.0-dev.7) (2025-11-08)

### Bug Fixes

* **YouTube - Settings:** Add additional languages to ReVanced language preference ([d390b54](d390b54dab))
2025-11-08 12:32:08 +00:00
LisoUseInAIKyrios
d390b54dab fix(YouTube - Settings): Add additional languages to ReVanced language preference 2025-11-08 14:28:52 +02:00
github-actions[bot]
4d1eaa6b14 chore: Sync translations (#6260) 2025-11-08 14:26:20 +02:00
LisoUseInAIKyrios
c6364f5b49 chore: Fix compilation warning 2025-11-08 10:08:25 +02:00
semantic-release-bot
f177eae385 chore: Release v5.46.0-dev.6 [skip ci]
# [5.46.0-dev.6](https://github.com/ReVanced/revanced-patches/compare/v5.46.0-dev.5...v5.46.0-dev.6) (2025-11-08)

### Features

* **YouTube - Debugging:** Add setting to block experimental client flags ([#6196](https://github.com/ReVanced/revanced-patches/issues/6196)) ([2e9d695](2e9d6959c9))
2025-11-08 08:07:41 +00:00
MarcaD
2e9d6959c9 feat(YouTube - Debugging): Add setting to block experimental client flags (#6196) 2025-11-08 10:01:46 +02:00
semantic-release-bot
81f83690d6 chore: Release v5.46.0-dev.5 [skip ci]
# [5.46.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v5.46.0-dev.4...v5.46.0-dev.5) (2025-11-07)

### Bug Fixes

* **Duolingo - Disable ads:** Constrain patch to last working app target ([f238ae9](f238ae9895))
* **Instagram - Hide navigation buttons:** Constrain patch to last working app target ([e030e9c](e030e9c07a))
* **Spotify - Hide Create button:** Remove obsolete patch that is no longer needed ([#6252](https://github.com/ReVanced/revanced-patches/issues/6252)) ([59d85b2](59d85b28a7))
2025-11-07 11:39:00 +00:00
LisoUseInAIKyrios
f1bd6848c9 chore(deps-dev): Revert bump semantic-release from 24.2.9 to 25.0.1 (#6204)
This reverts commit 55e1a6784b.
2025-11-07 13:36:18 +02:00
LisoUseInAIKyrios
59d85b28a7 fix(Spotify - Hide Create button): Remove obsolete patch that is no longer needed (#6252) 2025-11-07 13:29:34 +02:00
LisoUseInAIKyrios
f238ae9895 fix(Duolingo - Disable ads): Constrain patch to last working app target 2025-11-07 13:27:31 +02:00
LisoUseInAIKyrios
e030e9c07a fix(Instagram - Hide navigation buttons): Constrain patch to last working app target 2025-11-07 13:25:24 +02:00
dependabot[bot]
5029e979be chore(deps): Bump actions/upload-artifact from 4 to 5 (#6201) 2025-11-07 09:34:24 +02:00
dependabot[bot]
55e1a6784b chore(deps-dev): Bump semantic-release from 24.2.9 to 25.0.1 (#6204) 2025-11-07 09:33:23 +02:00
dependabot[bot]
0cad5e73f0 chore(deps): Bump actions/setup-node from 5 to 6 (#6200) 2025-11-07 09:33:05 +02:00
semantic-release-bot
ce503d5b58 chore: Release v5.46.0-dev.4 [skip ci]
# [5.46.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.46.0-dev.3...v5.46.0-dev.4) (2025-11-07)

### Bug Fixes

* **YouTube - Check watch history domain name resolution:** Fix false positive warning message if the internet connection fails halfway into the DNS check ([5726353](57263538c7))
2025-11-07 07:17:59 +00:00
LisoUseInAIKyrios
57263538c7 fix(YouTube - Check watch history domain name resolution): Fix false positive warning message if the internet connection fails halfway into the DNS check 2025-11-07 09:14:21 +02:00
semantic-release-bot
70f4955e89 chore: Release v5.46.0-dev.3 [skip ci]
# [5.46.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.46.0-dev.2...v5.46.0-dev.3) (2025-11-06)

### Bug Fixes

* **YouTube - Hide layout components:** Fix "Hide Hype points" ([#6247](https://github.com/ReVanced/revanced-patches/issues/6247)) ([5821440](582144026d))
2025-11-06 12:12:38 +00:00
ILoveOpenSourceApplications
582144026d fix(YouTube - Hide layout components): Fix "Hide Hype points" (#6247) 2025-11-06 14:09:43 +02:00
github-actions[bot]
d80892cc0e chore: Sync translations (#6248) 2025-11-06 14:09:25 +02:00
semantic-release-bot
6c4b931b8a chore: Release v5.46.0-dev.2 [skip ci]
# [5.46.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.46.0-dev.1...v5.46.0-dev.2) (2025-11-04)

### Bug Fixes

* **YouTube - Settings:** Resolve settings search crash when searching for specific words ([#6231](https://github.com/ReVanced/revanced-patches/issues/6231)) ([76dcfae](76dcfaefd8))
2025-11-04 13:03:33 +00:00
MarcaD
76dcfaefd8 fix(YouTube - Settings): Resolve settings search crash when searching for specific words (#6231) 2025-11-04 14:59:43 +02:00
github-actions[bot]
e4f52343c0 chore: Sync translations (#6239) 2025-11-04 14:59:20 +02:00
semantic-release-bot
1196b1a147 chore: Release v5.46.0-dev.1 [skip ci]
# [5.46.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.45.0...v5.46.0-dev.1) (2025-11-04)

### Features

* **YouTube - Hide layout components:** Add "Hide Hype points" ([#6230](https://github.com/ReVanced/revanced-patches/issues/6230)) ([a52c015](a52c0153b1))
* **YouTube - Hide player flyout menu items:** Add "Hide Listen with YouTube Music" ([#6232](https://github.com/ReVanced/revanced-patches/issues/6232)) ([858edbf](858edbf3e7))
2025-11-04 07:10:24 +00:00
ILoveOpenSourceApplications
858edbf3e7 feat(YouTube - Hide player flyout menu items): Add "Hide Listen with YouTube Music" (#6232) 2025-11-04 09:07:11 +02:00
ILoveOpenSourceApplications
a52c0153b1 feat(YouTube - Hide layout components): Add "Hide Hype points" (#6230) 2025-11-04 09:05:28 +02:00
semantic-release-bot
cd9ef81354 chore: Release v5.45.0 [skip ci]
# [5.45.0](https://github.com/ReVanced/revanced-patches/compare/v5.44.0...v5.45.0) (2025-11-01)

### Bug Fixes

* **Instagram:** Update failing fingerprints on newer versions ([#6181](https://github.com/ReVanced/revanced-patches/issues/6181)) ([c73a03c](c73a03c9e1))
* **TikTok - Downloads:** Fix download path setting  ([#6191](https://github.com/ReVanced/revanced-patches/issues/6191)) ([3e4990a](3e4990afff))
* **YouTube - Change header:** Do not mirror header graphic with RTL languages ([a0c5604](a0c5604951))
* **YouTube - Force original audio:** Fall back to visionOS and not Android Studio if Android VR is not available ([6d01863](6d01863ec7))
* **YouTube - Spoof video streams:** Remove spoof stream audio selector that no longer works ([292fae4](292fae440c))
* **YouTube Music - Hide category bar:** Correctly hide the category bar in newer app targets ([#6175](https://github.com/ReVanced/revanced-patches/issues/6175)) ([13cf172](13cf1724bf))

### Features

* **Spoof video streams:** Add experimental "Android No SDK" client type ([5f23bfe](5f23bfe833))
* **TikTok:** Add `Sanitize sharing links` patch ([#6176](https://github.com/ReVanced/revanced-patches/issues/6176)) ([ef44eaa](ef44eaa119))
* **YouTube - Change Header:** Use SVG for header logo ([#6178](https://github.com/ReVanced/revanced-patches/issues/6178)) ([e9f45ce](e9f45ce926))
2025-11-01 15:36:32 +00:00
LisoUseInAIKyrios
1b2cd64a86 chore: Merge branch dev to main (#6174) 2025-11-01 16:32:23 +01:00
semantic-release-bot
0c03599f07 chore: Release v5.45.0-dev.6 [skip ci]
# [5.45.0-dev.6](https://github.com/ReVanced/revanced-patches/compare/v5.45.0-dev.5...v5.45.0-dev.6) (2025-11-01)

### Features

* **Spoof video streams:** Add experimental "Android No SDK" client type ([5f23bfe](5f23bfe833))
2025-11-01 08:25:19 +00:00
LisoUseInAIKyrios
5f23bfe833 feat(Spoof video streams): Add experimental "Android No SDK" client type 2025-11-01 09:19:49 +01:00
github-actions[bot]
2cf8f0e636 chore: Sync translations (#6197) 2025-11-01 09:17:01 +01:00
semantic-release-bot
c17cf98c7e chore: Release v5.45.0-dev.5 [skip ci]
# [5.45.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v5.45.0-dev.4...v5.45.0-dev.5) (2025-11-01)

### Bug Fixes

* **TikTok - Downloads:** Fix download path setting  ([#6191](https://github.com/ReVanced/revanced-patches/issues/6191)) ([3e4990a](3e4990afff))
* **YouTube - Spoof video streams:** Remove spoof stream audio selector that no longer works ([292fae4](292fae440c))
2025-11-01 07:36:49 +00:00
hxreborn
3e4990afff fix(TikTok - Downloads): Fix download path setting (#6191) 2025-11-01 08:32:26 +01:00
LisoUseInAIKyrios
292fae440c fix(YouTube - Spoof video streams): Remove spoof stream audio selector that no longer works 2025-11-01 08:31:17 +01:00
semantic-release-bot
12e7c0943a chore: Release v5.45.0-dev.4 [skip ci]
# [5.45.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.45.0-dev.3...v5.45.0-dev.4) (2025-10-30)

### Bug Fixes

* **YouTube - Change header:** Do not mirror header graphic with RTL languages ([a0c5604](a0c5604951))
2025-10-30 08:32:28 +00:00
LisoUseInAIKyrios
a0c5604951 fix(YouTube - Change header): Do not mirror header graphic with RTL languages 2025-10-30 09:27:27 +01:00
LisoUseInAIKyrios
38d9299dfe chore: Add branding license text file (#6179) 2025-10-30 09:26:18 +01:00
semantic-release-bot
dfdbbfa047 chore: Release v5.45.0-dev.3 [skip ci]
# [5.45.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.45.0-dev.2...v5.45.0-dev.3) (2025-10-27)

### Features

* **YouTube - Change Header:** Use SVG for header logo ([#6178](https://github.com/ReVanced/revanced-patches/issues/6178)) ([e9f45ce](e9f45ce926))
2025-10-27 17:14:52 +00:00
MarcaD
e9f45ce926 feat(YouTube - Change Header): Use SVG for header logo (#6178)
Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
2025-10-27 21:10:49 +04:00
semantic-release-bot
1b38b1a3c8 chore: Release v5.45.0-dev.2 [skip ci]
# [5.45.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.45.0-dev.1...v5.45.0-dev.2) (2025-10-26)

### Bug Fixes

* **YouTube - Force original audio:** Fall back to visionOS and not Android Studio if Android VR is not available ([6d01863](6d01863ec7))
* **YouTube Music - Hide category bar:** Correctly hide the category bar in newer app targets ([#6175](https://github.com/ReVanced/revanced-patches/issues/6175)) ([13cf172](13cf1724bf))
2025-10-26 15:27:23 +00:00
MarcaD
13cf1724bf fix(YouTube Music - Hide category bar): Correctly hide the category bar in newer app targets (#6175) 2025-10-26 19:23:40 +04:00
LisoUseInAIKyrios
6d01863ec7 fix(YouTube - Force original audio): Fall back to visionOS and not Android Studio if Android VR is not available 2025-10-26 19:23:04 +04:00
semantic-release-bot
a32ed30b4c chore: Release v5.45.0-dev.1 [skip ci]
# [5.45.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.44.0...v5.45.0-dev.1) (2025-10-26)

### Bug Fixes

* **Instagram:** Update failing fingerprints on newer versions ([#6181](https://github.com/ReVanced/revanced-patches/issues/6181)) ([c73a03c](c73a03c9e1))

### Features

* **TikTok:** Add `Sanitize sharing links` patch ([#6176](https://github.com/ReVanced/revanced-patches/issues/6176)) ([ef44eaa](ef44eaa119))
2025-10-26 10:05:58 +00:00
hxreborn
ef44eaa119 feat(TikTok): Add Sanitize sharing links patch (#6176) 2025-10-26 14:01:32 +04:00
rospino74
c73a03c9e1 fix(Instagram): Update failing fingerprints on newer versions (#6181) 2025-10-26 11:01:06 +01:00
LisoUseInAIKyrios
c1681f982a chore: Dump api 2025-10-25 08:11:57 +04:00
semantic-release-bot
1502bf7524 chore: Release v5.44.0 [skip ci]
# [5.44.0](https://github.com/ReVanced/revanced-patches/compare/v5.43.1...v5.44.0) (2025-10-24)

### Bug Fixes

* **Google Photos - Spoof features:** Add support for Pixel 10 devices ([#6161](https://github.com/ReVanced/revanced-patches/issues/6161)) ([754b719](754b71959a))
* **X / Twitter - Change link sharing domain:** Use bytecode patching to resolve patching with Manager ([#6125](https://github.com/ReVanced/revanced-patches/issues/6125)) ([0af8c8a](0af8c8a766))
* **YouTube - Hide layout components:** Hide new kind of community post ([#6146](https://github.com/ReVanced/revanced-patches/issues/6146)) ([cfd244b](cfd244b408))
* **YouTube Music:** Resolve patching 7.29 target ([2e4c6fd](2e4c6fdcad))

### Features

* Add `Custom network security` patch ([#6151](https://github.com/ReVanced/revanced-patches/issues/6151)) ([e7336d2](e7336d2ef3))
* **Duolingo - Enable debug menu:** Support latest app target ([#6163](https://github.com/ReVanced/revanced-patches/issues/6163)) ([08baa19](08baa19b4a))
* **Duolingo:** Add `Skip energy recharge ads` patch ([#6167](https://github.com/ReVanced/revanced-patches/issues/6167)) ([591e106](591e106098))
* **Samsung Radio:** Add `Disable device checks` patch ([#6145](https://github.com/ReVanced/revanced-patches/issues/6145)) ([de97562](de97562c5d))
2025-10-24 12:03:24 +00:00
LisoUseInAIKyrios
dffb1e6525 chore: Merge branch dev to main (#6135) 2025-10-24 15:59:21 +04:00
semantic-release-bot
b8c2ede2bf chore: Release v5.44.0-dev.4 [skip ci]
# [5.44.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.44.0-dev.3...v5.44.0-dev.4) (2025-10-24)

### Features

* Add `Custom network security` patch ([#6151](https://github.com/ReVanced/revanced-patches/issues/6151)) ([e7336d2](e7336d2ef3))
* **Duolingo:** Add `Skip energy recharge ads` patch ([#6167](https://github.com/ReVanced/revanced-patches/issues/6167)) ([591e106](591e106098))
2025-10-24 05:32:12 +00:00
hckrman101
591e106098 feat(Duolingo): Add Skip energy recharge ads patch (#6167) 2025-10-24 09:28:34 +04:00
Pawloland
e7336d2ef3 feat: Add Custom network security patch (#6151) 2025-10-24 09:27:12 +04:00
LisoUseInAIKyrios
7a53f8f62d chore(YouTube Music): Simplify fingerprint for upcoming app versions 2025-10-24 09:25:44 +04:00
github-actions[bot]
3466d9d210 chore: Sync translations (#6172) 2025-10-24 09:19:08 +04:00
LisoUseInAIKyrios
876d7a6b03 chore(X / Twitter - Change link sharing domain): Add disclaimer to description 2025-10-24 09:18:20 +04:00
semantic-release-bot
a2aa9cac27 chore: Release v5.44.0-dev.3 [skip ci]
# [5.44.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.44.0-dev.2...v5.44.0-dev.3) (2025-10-22)

### Features

* **Duolingo - Enable debug menu:** Support latest app target ([#6163](https://github.com/ReVanced/revanced-patches/issues/6163)) ([08baa19](08baa19b4a))
2025-10-22 20:24:37 +00:00
LisoUseInAIKyrios
08baa19b4a feat(Duolingo - Enable debug menu): Support latest app target (#6163) 2025-10-23 00:21:30 +04:00
semantic-release-bot
7283b93cea chore: Release v5.44.0-dev.2 [skip ci]
# [5.44.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.44.0-dev.1...v5.44.0-dev.2) (2025-10-22)

### Bug Fixes

* **Google Photos - Spoof features:** Add support for Pixel 10 devices ([#6161](https://github.com/ReVanced/revanced-patches/issues/6161)) ([754b719](754b71959a))
2025-10-22 16:53:15 +00:00
Lassie111
754b71959a fix(Google Photos - Spoof features): Add support for Pixel 10 devices (#6161) 2025-10-22 20:50:10 +04:00
LisoUseInAIKyrios
c64e29ec57 refactor(YouTube - Seekbar): Remove obsolete splash screen color code 2025-10-22 11:47:39 +04:00
semantic-release-bot
b2dd008aee chore: Release v5.44.0-dev.1 [skip ci]
# [5.44.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.43.2-dev.3...v5.44.0-dev.1) (2025-10-22)

### Features

* **Samsung Radio:** Add `Disable device checks` patch ([#6145](https://github.com/ReVanced/revanced-patches/issues/6145)) ([de97562](de97562c5d))
2025-10-22 06:14:00 +00:00
rospino74
de97562c5d feat(Samsung Radio): Add Disable device checks patch (#6145) 2025-10-22 10:09:34 +04:00
semantic-release-bot
6373829fd6 chore: Release v5.43.2-dev.3 [skip ci]
## [5.43.2-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.43.2-dev.2...v5.43.2-dev.3) (2025-10-19)

### Bug Fixes

* **YouTube - Hide layout components:** Hide new kind of community post ([#6146](https://github.com/ReVanced/revanced-patches/issues/6146)) ([cfd244b](cfd244b408))
2025-10-19 14:29:38 +00:00
Bceez
cfd244b408 fix(YouTube - Hide layout components): Hide new kind of community post (#6146) 2025-10-19 18:24:45 +04:00
semantic-release-bot
e8e28e2b6a chore: Release v5.43.2-dev.2 [skip ci]
## [5.43.2-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.43.2-dev.1...v5.43.2-dev.2) (2025-10-17)

### Bug Fixes

* **YouTube Music:** Resolve patching 7.29 target ([2e4c6fd](2e4c6fdcad))
2025-10-17 21:15:51 +00:00
LisoUseInAIKyrios
2e4c6fdcad fix(YouTube Music): Resolve patching 7.29 target 2025-10-18 01:12:34 +04:00
github-actions[bot]
644d6dcb51 chore: Sync translations (#6142) 2025-10-18 01:08:27 +04:00
semantic-release-bot
14dd7346a8 chore: Release v5.43.2-dev.1 [skip ci]
## [5.43.2-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.43.1...v5.43.2-dev.1) (2025-10-16)

### Bug Fixes

* **X / Twitter - Change link sharing domain:** Use bytecode patching to resolve patching with Manager ([#6125](https://github.com/ReVanced/revanced-patches/issues/6125)) ([0af8c8a](0af8c8a766))
2025-10-16 19:37:32 +00:00
ADudeCalledLeo
0af8c8a766 fix(X / Twitter - Change link sharing domain): Use bytecode patching to resolve patching with Manager (#6125) 2025-10-16 23:29:01 +04:00
semantic-release-bot
96454c843b chore: Release v5.43.1 [skip ci]
## [5.43.1](https://github.com/ReVanced/revanced-patches/compare/v5.43.0...v5.43.1) (2025-10-15)

### Bug Fixes

* **X / Twitter - Change link sharing domain:** Resolve duplicate patch option ([#6119](https://github.com/ReVanced/revanced-patches/issues/6119)) ([7563990](7563990750))
* **X / Twitter:** Do not crash Manager when clicking on domain patch option ([2a1e318](2a1e31860f))
2025-10-15 08:15:15 +00:00
LisoUseInAIKyrios
476ef0fae1 chore: Merge branch dev to main (#6121) 2025-10-15 12:12:25 +04:00
github-actions[bot]
bbec724afb chore: Sync translations (#6124) 2025-10-15 12:11:47 +04:00
semantic-release-bot
7a1dcbd4ee chore: Release v5.43.1-dev.2 [skip ci]
## [5.43.1-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.43.1-dev.1...v5.43.1-dev.2) (2025-10-14)

### Bug Fixes

* **X / Twitter:** Do not crash Manager when clicking on domain patch option ([2a1e318](2a1e31860f))
2025-10-14 19:47:15 +00:00
LisoUseInAIKyrios
2a1e31860f fix(X / Twitter): Do not crash Manager when clicking on domain patch option 2025-10-14 23:42:15 +04:00
semantic-release-bot
949d6bdd19 chore: Release v5.43.1-dev.1 [skip ci]
## [5.43.1-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.43.0...v5.43.1-dev.1) (2025-10-14)

### Bug Fixes

* **X / Twitter - Change link sharing domain:** Resolve duplicate patch option ([#6119](https://github.com/ReVanced/revanced-patches/issues/6119)) ([7563990](7563990750))
2025-10-14 18:19:42 +00:00
ADudeCalledLeo
7563990750 fix(X / Twitter - Change link sharing domain): Resolve duplicate patch option (#6119) 2025-10-14 22:16:01 +04:00
semantic-release-bot
4b605eb270 chore: Release v5.43.0 [skip ci]
# [5.43.0](https://github.com/ReVanced/revanced-patches/compare/v5.42.1...v5.43.0) (2025-10-14)

### Bug Fixes

* **Custom branding:** Use white notification icon for expanded status bar panel ([95eee59](95eee59a87))
* **Instagram - Change sharing domain:** Display patch option ([#6089](https://github.com/ReVanced/revanced-patches/issues/6089)) ([be2b144](be2b144cc9))
* **X / Twitter - Change Link Sharing Domain:** Change link domain of share copy action ([#6091](https://github.com/ReVanced/revanced-patches/issues/6091)) ([5484625](54846253d7))
* **YouTube - Custom branding:** Do not add a broken custom icon if the user provides an invalid custom icon path ([6555f6e](6555f6e6f8))
* **YouTube - Custom branding:** Use ReVanced icon for status bar notification icon ([#6108](https://github.com/ReVanced/revanced-patches/issues/6108)) ([10ea250](10ea250d4a))
* **YouTube - Force original audio:** Do not use translated audio if stream spoofing is off and force audio is on ([0c19dba](0c19dbaf30))

### Features

* **Instagram:** Add `Hide suggested content` patch ([#6075](https://github.com/ReVanced/revanced-patches/issues/6075)) ([50f0b9c](50f0b9c5ee))
2025-10-14 12:03:58 +00:00
LisoUseInAIKyrios
c2d7a7fb8b chore: Merge branch dev to main (#6090) 2025-10-14 16:00:00 +04:00
github-actions[bot]
a55560dc25 chore: Sync translations (#6118) 2025-10-14 15:58:38 +04:00
semantic-release-bot
e8522d703e chore: Release v5.43.0-dev.4 [skip ci]
# [5.43.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.43.0-dev.3...v5.43.0-dev.4) (2025-10-14)

### Bug Fixes

* **YouTube - Force original audio:** Do not use translated audio if stream spoofing is off and force audio is on ([0c19dba](0c19dbaf30))
2025-10-14 11:46:49 +00:00
LisoUseInAIKyrios
068d029a03 refactor: Use notNull delegate to prevent wasting more time in the future 2025-10-14 15:43:05 +04:00
LisoUseInAIKyrios
0c19dbaf30 fix(YouTube - Force original audio): Do not use translated audio if stream spoofing is off and force audio is on 2025-10-14 15:38:05 +04:00
semantic-release-bot
bf73ac8316 chore: Release v5.43.0-dev.3 [skip ci]
# [5.43.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.43.0-dev.2...v5.43.0-dev.3) (2025-10-14)

### Bug Fixes

* **Custom branding:** Use white notification icon for expanded status bar panel ([95eee59](95eee59a87))
2025-10-14 10:39:28 +00:00
LisoUseInAIKyrios
95eee59a87 fix(Custom branding): Use white notification icon for expanded status bar panel 2025-10-14 14:36:09 +04:00
semantic-release-bot
566875ea53 chore: Release v5.43.0-dev.2 [skip ci]
# [5.43.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.43.0-dev.1...v5.43.0-dev.2) (2025-10-14)

### Bug Fixes

* **YouTube - Custom branding:** Use ReVanced icon for status bar notification icon ([#6108](https://github.com/ReVanced/revanced-patches/issues/6108)) ([10ea250](10ea250d4a))
2025-10-14 05:58:15 +00:00
LisoUseInAIKyrios
10ea250d4a fix(YouTube - Custom branding): Use ReVanced icon for status bar notification icon (#6108) 2025-10-14 09:54:14 +04:00
github-actions[bot]
5bd0f11630 chore: Sync translations (#6117) 2025-10-14 09:53:13 +04:00
semantic-release-bot
4547ecb73c chore: Release v5.43.0-dev.1 [skip ci]
# [5.43.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.42.2-dev.3...v5.43.0-dev.1) (2025-10-11)

### Features

* **Instagram:** Add `Hide suggested content` patch ([#6075](https://github.com/ReVanced/revanced-patches/issues/6075)) ([50f0b9c](50f0b9c5ee))
2025-10-11 11:22:05 +00:00
Swakshan
50f0b9c5ee feat(Instagram): Add Hide suggested content patch (#6075) 2025-10-11 15:17:24 +04:00
semantic-release-bot
a8c4bdb8a6 chore: Release v5.42.2-dev.3 [skip ci]
## [5.42.2-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.42.2-dev.2...v5.42.2-dev.3) (2025-10-11)

### Bug Fixes

* **YouTube - Custom branding:** Do not add a broken custom icon if the user provides an invalid custom icon path ([6555f6e](6555f6e6f8))
2025-10-11 08:04:05 +00:00
LisoUseInAIKyrios
6555f6e6f8 fix(YouTube - Custom branding): Do not add a broken custom icon if the user provides an invalid custom icon path 2025-10-11 12:00:26 +04:00
semantic-release-bot
a0e2c5c7b9 chore: Release v5.42.2-dev.2 [skip ci]
## [5.42.2-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.42.2-dev.1...v5.42.2-dev.2) (2025-10-10)

### Bug Fixes

* **X / Twitter - Change Link Sharing Domain:** Change link domain of share copy action ([#6091](https://github.com/ReVanced/revanced-patches/issues/6091)) ([5484625](54846253d7))
2025-10-10 22:05:28 +00:00
ADudeCalledLeo
54846253d7 fix(X / Twitter - Change Link Sharing Domain): Change link domain of share copy action (#6091)
Co-authored-by: nyraa <112930946+nyraa@users.noreply.github.com>
2025-10-11 02:01:44 +04:00
github-actions[bot]
a98e8f7370 chore: Sync translations (#6097) 2025-10-11 02:01:17 +04:00
semantic-release-bot
2d928e0cd6 chore: Release v5.42.2-dev.1 [skip ci]
## [5.42.2-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.42.1...v5.42.2-dev.1) (2025-10-09)

### Bug Fixes

* **Instagram - Change sharing domain:** Display patch option ([#6089](https://github.com/ReVanced/revanced-patches/issues/6089)) ([be2b144](be2b144cc9))
2025-10-09 08:03:53 +00:00
brosssh
be2b144cc9 fix(Instagram - Change sharing domain): Display patch option (#6089) 2025-10-09 10:00:17 +02:00
semantic-release-bot
52c0bb6aa2 chore: Release v5.42.1 [skip ci]
## [5.42.1](https://github.com/ReVanced/revanced-patches/compare/v5.42.0...v5.42.1) (2025-10-08)

### Bug Fixes

* **YouTube - Custom Branding:** Resolve startup crash with root installation ([fd4b2e1](fd4b2e1bb9))
2025-10-08 07:47:41 +00:00
LisoUseInAIKyrios
38a49cc2a1 chore: Merge branch dev to main (#6080) 2025-10-08 11:44:39 +04:00
semantic-release-bot
91044b3a50 chore: Release v5.42.1-dev.1 [skip ci]
## [5.42.1-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.42.0...v5.42.1-dev.1) (2025-10-08)

### Bug Fixes

* **YouTube - Custom Branding:** Resolve startup crash with root installation ([fd4b2e1](fd4b2e1bb9))
2025-10-08 07:42:49 +00:00
LisoUseInAIKyrios
fd4b2e1bb9 fix(YouTube - Custom Branding): Resolve startup crash with root installation 2025-10-08 11:39:48 +04:00
semantic-release-bot
d0f20c8c7f chore: Release v5.42.0 [skip ci]
# [5.42.0](https://github.com/ReVanced/revanced-patches/compare/v5.41.0...v5.42.0) (2025-10-08)

### Bug Fixes

* **Custom branding:** Update ReVanced logo ([#6049](https://github.com/ReVanced/revanced-patches/issues/6049)) ([9441e7a](9441e7acb4))
* **Custom branding:** Update ReVanced logo sizing ([#6029](https://github.com/ReVanced/revanced-patches/issues/6029)) ([ae4b947](ae4b9474d3))
* **Instagram - Hide navigation buttons:** Resolve app startup crash ([080a226](080a226614))
* **Spotify:** Change `Hide Create button` patch to default off ([#6067](https://github.com/ReVanced/revanced-patches/issues/6067)) ([19949e1](19949e1695))
* **X / Twitter:** Remove non functional and obsolete patch `Open links with app chooser` ([#6033](https://github.com/ReVanced/revanced-patches/issues/6033)) ([673609c](673609c2aa))
* **YouTube - Force original audio:** Change patch to default on ([#6070](https://github.com/ReVanced/revanced-patches/issues/6070)) ([bd4ba2d](bd4ba2dae8))
* **YouTube - Force original language:** Resolve some videos using Swedish audio track ([9d67316](9d6731660b))
* **YouTube - Hide end screen cards:** Hide new type of end screen card ([#6027](https://github.com/ReVanced/revanced-patches/issues/6027)) ([76b0364](76b0364c5b))
* **YouTube - Spoof video streams:** Add "Allow Android VR AV1" setting ([#6071](https://github.com/ReVanced/revanced-patches/issues/6071)) ([f03256c](f03256c471))
* **YouTube - Spoof video streams:** Do not allow VR AV1 if "Force AVC" is enabled ([7afeaeb](7afeaebb5c))
* **YouTube - Spoof video streams:** Resolve playback dropping frames ([#6051](https://github.com/ReVanced/revanced-patches/issues/6051)) ([a62ee43](a62ee43441))
* **YouTube Music - GmsCore support:** Handle sharing links to certain apps such as Instagram ([#6026](https://github.com/ReVanced/revanced-patches/issues/6026)) ([328234f](328234f39a))
* **YouTube Music - Hide cast button:** Fix patching error ([28799a5](28799a548a))
* **YouTube Music - Hide cast button:** Resolve button not hiding ([7817885](7817885cff))
* **YouTube:** Resolve UI components not hiding for some users ([#6054](https://github.com/ReVanced/revanced-patches/issues/6054)) ([6b26346](6b26346914))

### Features

* **Custom branding:** Add in-app settings to change icon and name ([#6059](https://github.com/ReVanced/revanced-patches/issues/6059)) ([a50f3b5](a50f3b5177))
* **Instagram:** Add `Custom share domain` patch ([#5998](https://github.com/ReVanced/revanced-patches/issues/5998)) ([20c4131](20c413120b))
* **Instagram:** Add `Enable developer menu` patch ([#6043](https://github.com/ReVanced/revanced-patches/issues/6043)) ([2154d89](2154d89242))
* **Instagram:** Add `Open links externally` patch ([#6012](https://github.com/ReVanced/revanced-patches/issues/6012)) ([08e8ead](08e8ead04f))
* **Instagram:** Add `Sanitize sharing links` patch ([#5986](https://github.com/ReVanced/revanced-patches/issues/5986)) ([963a4ef](963a4ef43f))
* **Viber:** Add `Hide navigation buttons` patch ([#5991](https://github.com/ReVanced/revanced-patches/issues/5991)) ([5cb46c4](5cb46c4e91))
* **YouTube Music:** Add `Custom branding` patch ([#6007](https://github.com/ReVanced/revanced-patches/issues/6007)) ([4c8b56f](4c8b56f546))
* **YouTube Music:** Add `Force original audio` patch ([#6036](https://github.com/ReVanced/revanced-patches/issues/6036)) ([d0d53d1](d0d53d109e))
2025-10-08 06:16:06 +00:00
semantic-release-bot
d65dbc749c chore: Release v5.42.0 [skip ci]
# [5.42.0](https://github.com/ReVanced/revanced-patches/compare/v5.41.0...v5.42.0) (2025-10-08)

### Bug Fixes

* **Custom branding:** Update ReVanced logo ([#6049](https://github.com/ReVanced/revanced-patches/issues/6049)) ([9441e7a](9441e7acb4))
* **Custom branding:** Update ReVanced logo sizing ([#6029](https://github.com/ReVanced/revanced-patches/issues/6029)) ([ae4b947](ae4b9474d3))
* **Instagram - Hide navigation buttons:** Resolve app startup crash ([080a226](080a226614))
* **Spotify:** Change `Hide Create button` patch to default off ([#6067](https://github.com/ReVanced/revanced-patches/issues/6067)) ([19949e1](19949e1695))
* **X / Twitter:** Remove non functional and obsolete patch `Open links with app chooser` ([#6033](https://github.com/ReVanced/revanced-patches/issues/6033)) ([673609c](673609c2aa))
* **YouTube - Force original audio:** Change patch to default on ([#6070](https://github.com/ReVanced/revanced-patches/issues/6070)) ([bd4ba2d](bd4ba2dae8))
* **YouTube - Force original language:** Resolve some videos using Swedish audio track ([9d67316](9d6731660b))
* **YouTube - Hide end screen cards:** Hide new type of end screen card ([#6027](https://github.com/ReVanced/revanced-patches/issues/6027)) ([76b0364](76b0364c5b))
* **YouTube - Spoof video streams:** Add "Allow Android VR AV1" setting ([#6071](https://github.com/ReVanced/revanced-patches/issues/6071)) ([f03256c](f03256c471))
* **YouTube - Spoof video streams:** Do not allow VR AV1 if "Force AVC" is enabled ([7afeaeb](7afeaebb5c))
* **YouTube - Spoof video streams:** Resolve playback dropping frames ([#6051](https://github.com/ReVanced/revanced-patches/issues/6051)) ([a62ee43](a62ee43441))
* **YouTube Music - GmsCore support:** Handle sharing links to certain apps such as Instagram ([#6026](https://github.com/ReVanced/revanced-patches/issues/6026)) ([328234f](328234f39a))
* **YouTube Music - Hide cast button:** Fix patching error ([28799a5](28799a548a))
* **YouTube Music - Hide cast button:** Resolve button not hiding ([7817885](7817885cff))
* **YouTube:** Resolve UI components not hiding for some users ([#6054](https://github.com/ReVanced/revanced-patches/issues/6054)) ([6b26346](6b26346914))

### Features

* **Custom branding:** Add in-app settings to change icon and name ([#6059](https://github.com/ReVanced/revanced-patches/issues/6059)) ([a50f3b5](a50f3b5177))
* **Instagram:** Add `Custom share domain` patch ([#5998](https://github.com/ReVanced/revanced-patches/issues/5998)) ([20c4131](20c413120b))
* **Instagram:** Add `Enable developer menu` patch ([#6043](https://github.com/ReVanced/revanced-patches/issues/6043)) ([2154d89](2154d89242))
* **Instagram:** Add `Open links externally` patch ([#6012](https://github.com/ReVanced/revanced-patches/issues/6012)) ([08e8ead](08e8ead04f))
* **Instagram:** Add `Sanitize sharing links` patch ([#5986](https://github.com/ReVanced/revanced-patches/issues/5986)) ([963a4ef](963a4ef43f))
* **Viber:** Add `Hide navigation buttons` patch ([#5991](https://github.com/ReVanced/revanced-patches/issues/5991)) ([5cb46c4](5cb46c4e91))
* **YouTube Music:** Add `Custom branding` patch ([#6007](https://github.com/ReVanced/revanced-patches/issues/6007)) ([4c8b56f](4c8b56f546))
* **YouTube Music:** Add `Force original audio` patch ([#6036](https://github.com/ReVanced/revanced-patches/issues/6036)) ([d0d53d1](d0d53d109e))
2025-10-08 06:01:45 +00:00
LisoUseInAIKyrios
143dcef2b8 chore: Merge branch dev to main (#6015) 2025-10-08 09:57:48 +04:00
github-actions[bot]
dbfc5be464 chore: Sync translations (#6078) 2025-10-08 09:54:53 +04:00
LisoUseInAIKyrios
0fe545cad6 chore: Add links to the ReVanced brand guidelines 2025-10-08 09:47:27 +04:00
semantic-release-bot
feca17be68 chore: Release v5.42.0-dev.19 [skip ci]
# [5.42.0-dev.19](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.18...v5.42.0-dev.19) (2025-10-07)

### Bug Fixes

* **YouTube - Spoof video streams:** Do not allow VR AV1 if "Force AVC" is enabled ([7afeaeb](7afeaebb5c))
2025-10-07 20:37:44 +00:00
LisoUseInAIKyrios
7afeaebb5c fix(YouTube - Spoof video streams): Do not allow VR AV1 if "Force AVC" is enabled 2025-10-08 00:34:45 +04:00
github-actions[bot]
60a581a632 chore: Sync translations (#6077) 2025-10-08 00:30:56 +04:00
LisoUseInAIKyrios
104d096ada chore: Change brand name to untranslatable 2025-10-07 23:53:51 +04:00
semantic-release-bot
19dcbd8efb chore: Release v5.42.0-dev.18 [skip ci]
# [5.42.0-dev.18](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.17...v5.42.0-dev.18) (2025-10-07)

### Features

* **Custom branding:** Add in-app settings to change icon and name ([#6059](https://github.com/ReVanced/revanced-patches/issues/6059)) ([a50f3b5](a50f3b5177))
2025-10-07 19:25:25 +00:00
MarcaD
a50f3b5177 feat(Custom branding): Add in-app settings to change icon and name (#6059)
Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
2025-10-07 23:21:12 +04:00
semantic-release-bot
64d22a9c31 chore: Release v5.42.0-dev.17 [skip ci]
# [5.42.0-dev.17](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.16...v5.42.0-dev.17) (2025-10-07)

### Bug Fixes

* **YouTube - Force original audio:** Change patch to default on ([#6070](https://github.com/ReVanced/revanced-patches/issues/6070)) ([bd4ba2d](bd4ba2dae8))
2025-10-07 15:46:50 +00:00
LisoUseInAIKyrios
bd4ba2dae8 fix(YouTube - Force original audio): Change patch to default on (#6070) 2025-10-07 19:41:32 +04:00
github-actions[bot]
f51b260d1d chore: Sync translations (#6073) 2025-10-07 19:40:18 +04:00
LisoUseInAIKyrios
63be54dd09 chore: Remove unneeded binary compatibility for code that was never released to main 2025-10-07 19:39:54 +04:00
semantic-release-bot
bb222d7a26 chore: Release v5.42.0-dev.16 [skip ci]
# [5.42.0-dev.16](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.15...v5.42.0-dev.16) (2025-10-07)

### Bug Fixes

* **YouTube - Spoof video streams:** Add "Allow Android VR AV1" setting ([#6071](https://github.com/ReVanced/revanced-patches/issues/6071)) ([f03256c](f03256c471))
2025-10-07 15:19:06 +00:00
LisoUseInAIKyrios
f03256c471 fix(YouTube - Spoof video streams): Add "Allow Android VR AV1" setting (#6071) 2025-10-07 19:15:37 +04:00
semantic-release-bot
fe16433f20 chore: Release v5.42.0-dev.15 [skip ci]
# [5.42.0-dev.15](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.14...v5.42.0-dev.15) (2025-10-07)

### Features

* **Instagram:** Add `Enable developer menu` patch ([#6043](https://github.com/ReVanced/revanced-patches/issues/6043)) ([2154d89](2154d89242))
2025-10-07 12:42:11 +00:00
brosssh
2154d89242 feat(Instagram): Add Enable developer menu patch (#6043) 2025-10-07 16:37:20 +04:00
semantic-release-bot
277a8b6b47 chore: Release v5.42.0-dev.14 [skip ci]
# [5.42.0-dev.14](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.13...v5.42.0-dev.14) (2025-10-07)

### Features

* **Instagram:** Add `Custom share domain` patch ([#5998](https://github.com/ReVanced/revanced-patches/issues/5998)) ([20c4131](20c413120b))
2025-10-07 11:45:19 +00:00
brosssh
20c413120b feat(Instagram): Add Custom share domain patch (#5998) 2025-10-07 15:40:37 +04:00
semantic-release-bot
5ed092bb7d chore: Release v5.42.0-dev.13 [skip ci]
# [5.42.0-dev.13](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.12...v5.42.0-dev.13) (2025-10-07)

### Bug Fixes

* **Spotify:** Change `Hide Create button` patch to default off ([#6067](https://github.com/ReVanced/revanced-patches/issues/6067)) ([19949e1](19949e1695))
2025-10-07 07:14:51 +00:00
Dawid Krajcarz
19949e1695 fix(Spotify): Change Hide Create button patch to default off (#6067) 2025-10-07 11:11:41 +04:00
github-actions[bot]
ec0acc0f13 chore: Sync translations (#6069) 2025-10-07 11:11:18 +04:00
LisoUseInAIKyrios
a30a849e6e refactor: Extract shared patch names/descriptions (#6056) 2025-10-07 01:15:03 +04:00
semantic-release-bot
603025a122 chore: Release v5.42.0-dev.12 [skip ci]
# [5.42.0-dev.12](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.11...v5.42.0-dev.12) (2025-10-03)

### Bug Fixes

* **Custom branding:** Update ReVanced logo ([#6049](https://github.com/ReVanced/revanced-patches/issues/6049)) ([9441e7a](9441e7acb4))

### Features

* **Instagram:** Add `Sanitize sharing links` patch ([#5986](https://github.com/ReVanced/revanced-patches/issues/5986)) ([963a4ef](963a4ef43f))
2025-10-03 07:25:03 +00:00
MarcaD
9441e7acb4 fix(Custom branding): Update ReVanced logo (#6049) 2025-10-03 11:19:27 +04:00
brosssh
963a4ef43f feat(Instagram): Add Sanitize sharing links patch (#5986)
Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
2025-10-03 11:19:01 +04:00
semantic-release-bot
0acba30245 chore: Release v5.42.0-dev.11 [skip ci]
# [5.42.0-dev.11](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.10...v5.42.0-dev.11) (2025-10-03)

### Bug Fixes

* **YouTube:** Resolve UI components not hiding for some users ([#6054](https://github.com/ReVanced/revanced-patches/issues/6054)) ([6b26346](6b26346914))
2025-10-03 06:57:20 +00:00
LisoUseInAIKyrios
6b26346914 fix(YouTube): Resolve UI components not hiding for some users (#6054) 2025-10-03 10:54:44 +04:00
github-actions[bot]
b1511c732d chore: Sync translations (#6055) 2025-10-03 10:53:07 +04:00
semantic-release-bot
26117e744c chore: Release v5.42.0-dev.10 [skip ci]
# [5.42.0-dev.10](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.9...v5.42.0-dev.10) (2025-10-02)

### Bug Fixes

* **YouTube - Spoof video streams:** Resolve playback dropping frames ([#6051](https://github.com/ReVanced/revanced-patches/issues/6051)) ([a62ee43](a62ee43441))
2025-10-02 20:09:02 +00:00
LisoUseInAIKyrios
a62ee43441 fix(YouTube - Spoof video streams): Resolve playback dropping frames (#6051) 2025-10-03 00:05:23 +04:00
LisoUseInAIKyrios
6a799110d7 refactor(YouTube - Spoof video streams): Add 'supportsMultiAudioTracks' field 2025-10-01 22:37:10 +04:00
dependabot[bot]
aec17b93f7 chore(deps): Bump com.google.guava:guava from 33.4.0-jre to 33.5.0-jre (#6042) 2025-10-01 22:18:24 +04:00
dependabot[bot]
e7a1706be4 chore(deps): Bump actions/setup-node from 4 to 5 (#6038) 2025-10-01 22:16:40 +04:00
dependabot[bot]
9469604fe0 chore(deps-dev): Bump semantic-release from 24.2.7 to 24.2.9 (#6040) 2025-10-01 22:15:52 +04:00
semantic-release-bot
1a3a12df1a chore: Release v5.42.0-dev.9 [skip ci]
# [5.42.0-dev.9](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.8...v5.42.0-dev.9) (2025-10-01)

### Bug Fixes

* **Custom branding:** Update ReVanced logo sizing ([#6029](https://github.com/ReVanced/revanced-patches/issues/6029)) ([ae4b947](ae4b9474d3))
2025-10-01 17:33:14 +00:00
MarcaD
ae4b9474d3 fix(Custom branding): Update ReVanced logo sizing (#6029) 2025-10-01 21:29:05 +04:00
github-actions[bot]
83ccd9d3f1 chore: Sync translations (#6037) 2025-10-01 19:04:51 +04:00
semantic-release-bot
526c7c05e2 chore: Release v5.42.0-dev.8 [skip ci]
# [5.42.0-dev.8](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.7...v5.42.0-dev.8) (2025-10-01)

### Bug Fixes

* **YouTube - Force original language:** Resolve some videos using Swedish audio track ([9d67316](9d6731660b))

### Features

* **YouTube Music:** Add `Force original audio` patch ([#6036](https://github.com/ReVanced/revanced-patches/issues/6036)) ([d0d53d1](d0d53d109e))
2025-10-01 15:04:39 +00:00
LisoUseInAIKyrios
d0d53d109e feat(YouTube Music): Add Force original audio patch (#6036) 2025-10-01 18:59:16 +04:00
LisoUseInAIKyrios
9d6731660b fix(YouTube - Force original language): Resolve some videos using Swedish audio track 2025-10-01 18:57:53 +04:00
semantic-release-bot
5a7e199162 chore: Release v5.42.0-dev.7 [skip ci]
# [5.42.0-dev.7](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.6...v5.42.0-dev.7) (2025-10-01)

### Features

* **Instagram:** Add `Open links externally` patch ([#6012](https://github.com/ReVanced/revanced-patches/issues/6012)) ([08e8ead](08e8ead04f))
2025-10-01 06:25:01 +00:00
LisoUseInAIKyrios
0c662c8e3b chore(deps): Bump actions/checkout from 4 to 5 2025-10-01 10:19:09 +04:00
Swakshan
08e8ead04f feat(Instagram): Add Open links externally patch (#6012) 2025-10-01 10:17:19 +04:00
semantic-release-bot
d238a42708 chore: Release v5.42.0-dev.6 [skip ci]
# [5.42.0-dev.6](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.5...v5.42.0-dev.6) (2025-09-30)

### Bug Fixes

* **X / Twitter:** Remove non functional and obsolete patch `Open links with app chooser` ([#6033](https://github.com/ReVanced/revanced-patches/issues/6033)) ([673609c](673609c2aa))
2025-09-30 20:09:49 +00:00
LisoUseInAIKyrios
673609c2aa fix(X / Twitter): Remove non functional and obsolete patch Open links with app chooser (#6033) 2025-10-01 00:06:40 +04:00
github-actions[bot]
5f1a485e8f chore: Sync translations (#6034) 2025-10-01 00:06:22 +04:00
LisoUseInAIKyrios
6961babee9 refactor(YouTube - Check watch history domain name resolution): Do not show redundant dialog cancel button 2025-09-30 12:11:06 +04:00
semantic-release-bot
328c9b6bbe chore: Release v5.42.0-dev.5 [skip ci]
# [5.42.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.4...v5.42.0-dev.5) (2025-09-28)

### Features

* **YouTube Music:** Add `Custom branding` patch ([#6007](https://github.com/ReVanced/revanced-patches/issues/6007)) ([4c8b56f](4c8b56f546))
2025-09-28 11:30:51 +00:00
MarcaD
4c8b56f546 feat(YouTube Music): Add Custom branding patch (#6007)
Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
2025-09-28 15:26:12 +04:00
semantic-release-bot
1754023dd6 chore: Release v5.42.0-dev.4 [skip ci]
# [5.42.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.3...v5.42.0-dev.4) (2025-09-28)

### Bug Fixes

* **YouTube Music - GmsCore support:** Handle sharing links to certain apps such as Instagram ([#6026](https://github.com/ReVanced/revanced-patches/issues/6026)) ([328234f](328234f39a))
2025-09-28 10:34:39 +00:00
LisoUseInAIKyrios
328234f39a fix(YouTube Music - GmsCore support): Handle sharing links to certain apps such as Instagram (#6026) 2025-09-28 14:31:40 +04:00
github-actions[bot]
326953cfc3 chore: Sync translations (#6028) 2025-09-28 14:30:01 +04:00
semantic-release-bot
725d5dc974 chore: Release v5.42.0-dev.3 [skip ci]
# [5.42.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.2...v5.42.0-dev.3) (2025-09-28)

### Bug Fixes

* **YouTube - Hide end screen cards:** Hide new type of end screen card ([#6027](https://github.com/ReVanced/revanced-patches/issues/6027)) ([76b0364](76b0364c5b))
2025-09-28 10:25:37 +00:00
LisoUseInAIKyrios
76b0364c5b fix(YouTube - Hide end screen cards): Hide new type of end screen card (#6027) 2025-09-28 14:22:42 +04:00
semantic-release-bot
1cbff799ad chore: Release v5.42.0-dev.2 [skip ci]
# [5.42.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.42.0-dev.1...v5.42.0-dev.2) (2025-09-27)

### Bug Fixes

* **Instagram - Hide navigation buttons:** Resolve app startup crash ([080a226](080a226614))
2025-09-27 19:57:01 +00:00
LisoUseInAIKyrios
080a226614 fix(Instagram - Hide navigation buttons): Resolve app startup crash 2025-09-27 23:53:35 +04:00
semantic-release-bot
2b71bd80c2 chore: Release v5.42.0-dev.1 [skip ci]
# [5.42.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.41.1-dev.2...v5.42.0-dev.1) (2025-09-27)

### Features

* **Viber:** Add `Hide navigation buttons` patch ([#5991](https://github.com/ReVanced/revanced-patches/issues/5991)) ([5cb46c4](5cb46c4e91))
2025-09-27 13:02:45 +00:00
Samo Hribar
5cb46c4e91 feat(Viber): Add Hide navigation buttons patch (#5991) 2025-09-27 16:59:51 +04:00
semantic-release-bot
52c369576d chore: Release v5.41.1-dev.2 [skip ci]
## [5.41.1-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.41.1-dev.1...v5.41.1-dev.2) (2025-09-27)

### Bug Fixes

* **YouTube Music - Hide cast button:** Fix patching error ([28799a5](28799a548a))
2025-09-27 12:35:45 +00:00
LisoUseInAIKyrios
28799a548a fix(YouTube Music - Hide cast button): Fix patching error 2025-09-27 16:31:22 +04:00
semantic-release-bot
1c80774d79 chore: Release v5.41.1-dev.1 [skip ci]
## [5.41.1-dev.1](https://github.com/ReVanced/revanced-patches/compare/v5.41.0...v5.41.1-dev.1) (2025-09-27)

### Bug Fixes

* **YouTube Music - Hide cast button:** Resolve button not hiding ([7817885](7817885cff))
2025-09-27 12:24:18 +00:00
LisoUseInAIKyrios
7817885cff fix(YouTube Music - Hide cast button): Resolve button not hiding 2025-09-27 16:21:32 +04:00
LisoUseInAIKyrios
9afe9afc67 chore(YouTube): Fix patch description 2025-09-27 11:53:21 +04:00
semantic-release-bot
3a8091ae00 chore: Release v5.41.0 [skip ci]
# [5.41.0](https://github.com/ReVanced/revanced-patches/compare/v5.40.0...v5.41.0) (2025-09-27)

### Bug Fixes

* **Instagram - Hide navigation buttons:** Remove button based on name ([#5971](https://github.com/ReVanced/revanced-patches/issues/5971)) ([6fa4043](6fa404331b))
* **Instagram - Limit feed to followed profiles:** Preserve favorites feed ([#5963](https://github.com/ReVanced/revanced-patches/issues/5963)) ([ef51401](ef514017f4))
* **TikTok:** Show correct dialog restart text, use correct font color for non-dark mode ([d1a1293](d1a12930c3))
* **Twitch - Settings:** Fix missing style resources ([#5970](https://github.com/ReVanced/revanced-patches/issues/5970)) ([8c22995](8c229954d7))
* **YouTube - Hide Shorts components:** Fix "Hide preview comment" ([#5990](https://github.com/ReVanced/revanced-patches/issues/5990)) ([dd4e2cd](dd4e2cd085))
* **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:** Handle on screen back swipe gesture ([#6002](https://github.com/ReVanced/revanced-patches/issues/6002)) ([6f92b6c](6f92b6c50b))
* **YouTube - Settings:** Use an overlay to show search results ([#5806](https://github.com/ReVanced/revanced-patches/issues/5806)) ([ece8076](ece8076f7c))
* **YouTube - SponsorBlock:** Show category color dot in voting dialog menu ([4be00d0](4be00d09b7))
* **YouTube - SponsorBlock:** Show category color in create new segment menu ([#5987](https://github.com/ReVanced/revanced-patches/issues/5987)) ([ffd933c](ffd933c673))
* **YouTube - Spoof video streams:** Update client side effects summary text ([a0a62dd](a0a62ddad2))

### Features

* **Tumblr:** Add `Disable Tumblr TV` patch ([#5959](https://github.com/ReVanced/revanced-patches/issues/5959)) ([212418b](212418b8db))
* **YouTube - Hide layout components:** Add "Hide Emoji and Timestamp buttons" setting ([#5992](https://github.com/ReVanced/revanced-patches/issues/5992)) ([2b555f6](2b555f67f0))
* **YouTube - Hide layout components:** Add "Hide view count" and "Hide upload time" settings ([#5983](https://github.com/ReVanced/revanced-patches/issues/5983)) ([7a37d85](7a37d858fb))
* **YouTube - Loop video:** Add player button to change loop video state ([#5961](https://github.com/ReVanced/revanced-patches/issues/5961)) ([dfb5407](dfb5407e67))
* **YouTube - Spoof app version:** Add spoof target `20.05.46` that fixes transcript functionality ([5823f0e](5823f0e982))
* **YouTube Music:** Add `Check watch history domain name resolution` ([#5979](https://github.com/ReVanced/revanced-patches/issues/5979)) ([8af70fe](8af70fe2d1))
* **YouTube Music:** Add `Sanitize sharing links` patch ([#5952](https://github.com/ReVanced/revanced-patches/issues/5952)) ([45c1ee8](45c1ee8a12))
* **YouTube Music:** Add `Theme` patch ([#5984](https://github.com/ReVanced/revanced-patches/issues/5984)) ([3bd76d6](3bd76d60d6))
* **YouTube:** Add `Disable video codecs` patch ([#5981](https://github.com/ReVanced/revanced-patches/issues/5981)) ([bfbffbd](bfbffbd1f5))
2025-09-27 07:21:33 +00:00
LisoUseInAIKyrios
6192ece114 chore: Merge branch dev to main (#5950) 2025-09-27 11:17:09 +04:00
github-actions[bot]
5d9971444e chore: Sync translations (#6014) 2025-09-27 11:15:04 +04:00
semantic-release-bot
cdfa75dd5a chore: Release v5.41.0-dev.18 [skip ci]
# [5.41.0-dev.18](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.17...v5.41.0-dev.18) (2025-09-26)

### Bug Fixes

* **YouTube - Settings:** Handle on screen back swipe gesture ([#6002](https://github.com/ReVanced/revanced-patches/issues/6002)) ([6f92b6c](6f92b6c50b))
2025-09-26 15:05:03 +00:00
LisoUseInAIKyrios
6f92b6c50b fix(YouTube - Settings): Handle on screen back swipe gesture (#6002) 2025-09-26 19:00:12 +04:00
github-actions[bot]
1e023fa1f3 chore: Sync translations (#6010) 2025-09-26 18:59:48 +04:00
semantic-release-bot
00477bfebc chore: Release v5.41.0-dev.17 [skip ci]
# [5.41.0-dev.17](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.16...v5.41.0-dev.17) (2025-09-26)

### Bug Fixes

* **YouTube - SponsorBlock:** Show category color dot in voting dialog menu ([4be00d0](4be00d09b7))
2025-09-26 08:28:43 +00:00
LisoUseInAIKyrios
4be00d09b7 fix(YouTube - SponsorBlock): Show category color dot in voting dialog menu 2025-09-26 12:25:17 +04:00
github-actions[bot]
50aca3314f chore: Sync translations (#6005) 2025-09-26 12:24:08 +04:00
LisoUseInAIKyrios
15a7e540de refactor(YouTube - Miniplayer): Change ReVanced settings that are now YouTube default on into "Disable" style settings (#6003) 2025-09-26 12:14:20 +04:00
semantic-release-bot
041f7e0140 chore: Release v5.41.0-dev.16 [skip ci]
# [5.41.0-dev.16](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.15...v5.41.0-dev.16) (2025-09-26)

### Features

* **YouTube Music:** Add `Theme` patch ([#5984](https://github.com/ReVanced/revanced-patches/issues/5984)) ([3bd76d6](3bd76d60d6))
2025-09-26 05:33:20 +00:00
MarcaD
3bd76d60d6 feat(YouTube Music): Add Theme patch (#5984)
Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
2025-09-26 09:29:11 +04:00
github-actions[bot]
1587178ff8 chore: Sync translations (#6001) 2025-09-26 09:28:45 +04:00
semantic-release-bot
8a69240d66 chore: Release v5.41.0-dev.15 [skip ci]
# [5.41.0-dev.15](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.14...v5.41.0-dev.15) (2025-09-25)

### Features

* **YouTube - Hide layout components:** Add "Hide view count" and "Hide upload time" settings ([#5983](https://github.com/ReVanced/revanced-patches/issues/5983)) ([7a37d85](7a37d858fb))
2025-09-25 12:47:40 +00:00
viSapio
7a37d858fb feat(YouTube - Hide layout components): Add "Hide view count" and "Hide upload time" settings (#5983) 2025-09-25 16:43:59 +04:00
github-actions[bot]
0ed7067459 chore: Sync translations (#5996) 2025-09-25 16:38:32 +04:00
LisoUseInAIKyrios
6102644194 chore(YouTube): Adjust patch strings 2025-09-25 00:32:15 +04:00
semantic-release-bot
a89556a017 chore: Release v5.41.0-dev.14 [skip ci]
# [5.41.0-dev.14](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.13...v5.41.0-dev.14) (2025-09-24)

### Features

* **YouTube - Hide layout components:** Add "Hide Emoji and Timestamp buttons" setting ([#5992](https://github.com/ReVanced/revanced-patches/issues/5992)) ([2b555f6](2b555f67f0))
2025-09-24 20:21:22 +00:00
ILoveOpenSourceApplications
2b555f67f0 feat(YouTube - Hide layout components): Add "Hide Emoji and Timestamp buttons" setting (#5992) 2025-09-25 00:17:08 +04:00
semantic-release-bot
fb87199514 chore: Release v5.41.0-dev.13 [skip ci]
# [5.41.0-dev.13](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.12...v5.41.0-dev.13) (2025-09-24)

### Bug Fixes

* **YouTube - Hide Shorts components:** Fix "Hide preview comment" ([#5990](https://github.com/ReVanced/revanced-patches/issues/5990)) ([dd4e2cd](dd4e2cd085))
2025-09-24 19:44:03 +00:00
ILoveOpenSourceApplications
dd4e2cd085 fix(YouTube - Hide Shorts components): Fix "Hide preview comment" (#5990) 2025-09-24 23:41:15 +04:00
semantic-release-bot
fadc66816d chore: Release v5.41.0-dev.12 [skip ci]
# [5.41.0-dev.12](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.11...v5.41.0-dev.12) (2025-09-24)

### Bug Fixes

* **YouTube - SponsorBlock:** Show category color in create new segment menu ([#5987](https://github.com/ReVanced/revanced-patches/issues/5987)) ([ffd933c](ffd933c673))
2025-09-24 13:53:25 +00:00
MarcaD
ffd933c673 fix(YouTube - SponsorBlock): Show category color in create new segment menu (#5987) 2025-09-24 17:50:46 +04:00
github-actions[bot]
69883530b7 chore: Sync translations (#5989) 2025-09-24 17:49:49 +04:00
LisoUseInAIKyrios
39971291f3 chore: Fix typo 2025-09-24 15:56:29 +04:00
LisoUseInAIKyrios
51facf9321 chore(YouTube): Adjust UI dialog message 2025-09-24 09:46:37 +04:00
semantic-release-bot
b83d41ca88 chore: Release v5.41.0-dev.11 [skip ci]
# [5.41.0-dev.11](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.10...v5.41.0-dev.11) (2025-09-23)

### Features

* **YouTube:** Add `Disable video codecs` patch ([#5981](https://github.com/ReVanced/revanced-patches/issues/5981)) ([bfbffbd](bfbffbd1f5))
2025-09-23 18:10:52 +00:00
LisoUseInAIKyrios
bfbffbd1f5 feat(YouTube): Add Disable video codecs patch (#5981) 2025-09-23 22:06:02 +04:00
semantic-release-bot
ee4755646b chore: Release v5.41.0-dev.10 [skip ci]
# [5.41.0-dev.10](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.9...v5.41.0-dev.10) (2025-09-23)

### Bug Fixes

* **TikTok:** Show correct dialog restart text, use correct font color for non-dark mode ([d1a1293](d1a12930c3))
2025-09-23 17:46:05 +00:00
LisoUseInAIKyrios
d1a12930c3 fix(TikTok): Show correct dialog restart text, use correct font color for non-dark mode 2025-09-23 21:43:04 +04:00
semantic-release-bot
dfac836a8c chore: Release v5.41.0-dev.9 [skip ci]
# [5.41.0-dev.9](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.8...v5.41.0-dev.9) (2025-09-23)

### Bug Fixes

* **Instagram - Hide navigation buttons:** Remove button based on name ([#5971](https://github.com/ReVanced/revanced-patches/issues/5971)) ([6fa4043](6fa404331b))
2025-09-23 10:28:29 +00:00
brosssh
6fa404331b fix(Instagram - Hide navigation buttons): Remove button based on name (#5971) 2025-09-23 12:25:36 +02:00
semantic-release-bot
8bcb95adcd chore: Release v5.41.0-dev.8 [skip ci]
# [5.41.0-dev.8](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.7...v5.41.0-dev.8) (2025-09-23)

### Features

* **YouTube Music:** Add `Check watch history domain name resolution` ([#5979](https://github.com/ReVanced/revanced-patches/issues/5979)) ([8af70fe](8af70fe2d1))
2025-09-23 09:38:14 +00:00
LisoUseInAIKyrios
8af70fe2d1 feat(YouTube Music): Add Check watch history domain name resolution (#5979) 2025-09-23 13:34:00 +04:00
semantic-release-bot
191b9169ff chore: Release v5.41.0-dev.7 [skip ci]
# [5.41.0-dev.7](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.6...v5.41.0-dev.7) (2025-09-23)

### Features

* **Tumblr:** Add `Disable Tumblr TV` patch ([#5959](https://github.com/ReVanced/revanced-patches/issues/5959)) ([212418b](212418b8db))
2025-09-23 06:24:06 +00:00
Temm
212418b8db feat(Tumblr): Add Disable Tumblr TV patch (#5959) 2025-09-23 10:19:58 +04:00
github-actions[bot]
7dbc744be0 chore: Sync translations (#5978) 2025-09-23 10:18:20 +04:00
LisoUseInAIKyrios
150a3e7c60 chore(YouTube Music - GmsCore support): Add missing supported versions 2025-09-23 10:17:25 +04:00
semantic-release-bot
5027943470 chore: Release v5.41.0-dev.6 [skip ci]
# [5.41.0-dev.6](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.5...v5.41.0-dev.6) (2025-09-22)

### Features

* **YouTube - Spoof app version:** Add spoof target `20.05.46` that fixes transcript functionality ([5823f0e](5823f0e982))
2025-09-22 18:04:18 +00:00
github-actions[bot]
fa9e590b3a chore: Sync translations (#5972) 2025-09-22 22:01:33 +04:00
LisoUseInAIKyrios
5823f0e982 feat(YouTube - Spoof app version): Add spoof target 20.05.46 that fixes transcript functionality 2025-09-22 22:01:14 +04:00
LisoUseInAIKyrios
f506a67e4a chore(YouTube): Drop 19.43.41
Playback speed has a patch error. Don't want to fix. Most users want the latest or the oldest app target, and don't care about anything in-between.
2025-09-22 21:57:42 +04:00
semantic-release-bot
ed6e1155f2 chore: Release v5.41.0-dev.5 [skip ci]
# [5.41.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.4...v5.41.0-dev.5) (2025-09-22)

### Bug Fixes

* **Twitch - Settings:** Fix missing style resources ([#5970](https://github.com/ReVanced/revanced-patches/issues/5970)) ([8c22995](8c229954d7))
2025-09-22 16:05:55 +00:00
MarcaD
8c229954d7 fix(Twitch - Settings): Fix missing style resources (#5970) 2025-09-22 20:02:34 +04:00
semantic-release-bot
c5eb88bbf6 chore: Release v5.41.0-dev.4 [skip ci]
# [5.41.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.3...v5.41.0-dev.4) (2025-09-22)

### Bug Fixes

* **Instagram - Limit feed to followed profiles:** Preserve favorites feed ([#5963](https://github.com/ReVanced/revanced-patches/issues/5963)) ([ef51401](ef514017f4))
2025-09-22 09:35:02 +00:00
brosssh
ef514017f4 fix(Instagram - Limit feed to followed profiles): Preserve favorites feed (#5963) 2025-09-22 13:32:30 +04:00
github-actions[bot]
c72d99518c chore: Sync translations (#5968) 2025-09-22 13:32:12 +04:00
semantic-release-bot
772df6eb73 chore: Release v5.41.0-dev.3 [skip ci]
# [5.41.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.2...v5.41.0-dev.3) (2025-09-22)

### Features

* **YouTube - Loop video:** Add player button to change loop video state ([#5961](https://github.com/ReVanced/revanced-patches/issues/5961)) ([dfb5407](dfb5407e67))
2025-09-22 08:57:43 +00:00
MarcaD
dfb5407e67 feat(YouTube - Loop video): Add player button to change loop video state (#5961) 2025-09-22 12:54:09 +04:00
semantic-release-bot
6d5f6ecdd2 chore: Release v5.41.0-dev.2 [skip ci]
# [5.41.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v5.41.0-dev.1...v5.41.0-dev.2) (2025-09-21)

### Bug Fixes

* **YouTube - Spoof video streams:** Update client side effects summary text ([a0a62dd](a0a62ddad2))
2025-09-21 19:45:41 +00:00
LisoUseInAIKyrios
a0a62ddad2 fix(YouTube - Spoof video streams): Update client side effects summary text 2025-09-21 23:41:38 +04:00
github-actions[bot]
512e50e892 chore: Sync translations (#5955) 2025-09-21 23:03:49 +04:00
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
584 changed files with 34971 additions and 14099 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). - **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). - **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). - **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 - type: textarea
attributes: 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). - **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). - **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). - **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 - type: textarea
attributes: attributes:

View File

@@ -12,10 +12,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '17'
@@ -29,7 +29,7 @@ jobs:
run: ./gradlew :patches:buildAndroid --no-daemon run: ./gradlew :patches:buildAndroid --no-daemon
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: revanced-patches name: revanced-patches
path: patches/build/libs path: patches/build/libs

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -97,9 +97,9 @@ Thank you for considering contributing to ReVanced Patches. You can find the con
To build ReVanced Patches, you can follow the [ReVanced documentation](https://github.com/ReVanced/revanced-documentation). To build ReVanced Patches, you can follow the [ReVanced documentation](https://github.com/ReVanced/revanced-documentation).
## 📜 Licence ## 📜 License
ReVanced Patches is licensed under the GPLv3 license. Please see the [license file](LICENSE) for more information. ReVanced Patches is licensed under the GPLv3 license. Please see the [license file](LICENSE) for more information.
[tl;dr](https://www.tldrlegal.com/license/gnu-general-public-license-v3-gpl-3) you may copy, distribute and modify ReVanced Patches as long as you track changes/dates in source files. [tl;dr](https://www.tldrlegal.com/license/gnu-general-public-license-v3-gpl-3) you may copy, distribute and modify ReVanced Patches as long as you track changes/dates in source files.
Any modifications to ReVanced Patches must also be made available under the GPL, Any modifications to ReVanced Patches must also be made available under the GPL,
along with build & install instructions. along with build & install instructions.

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
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) {
String paginationHeaderName = "pagination_source";
// Patch the header only if it's trying to fetch the default feed
String currentHeader = requestHeaderMap.get(paginationHeaderName);
if (currentHeader != null && !currentHeader.equals("feed_recs")) {
return requestHeaderMap;
}
// Create new map as original is unmodifiable.
Map<String, String> patchedRequestHeaderMap = new HashMap<>(requestHeaderMap);
patchedRequestHeaderMap.put(paginationHeaderName, "following");
return patchedRequestHeaderMap;
}
}

View File

@@ -0,0 +1,33 @@
package app.revanced.extension.instagram.hide.navigation;
import java.lang.reflect.Field;
import java.util.List;
@SuppressWarnings("unused")
public class HideNavigationButtonsPatch {
/**
* Injection point.
* @param navigationButtonsList the list of navigation buttons, as an (obfuscated) Enum type
* @param buttonNameToRemove the name of the button we want to remove
* @param enumNameField the field in the nav button enum class which contains the name of the button
* @return the patched list of navigation buttons
*/
public static List<Object> removeNavigationButtonByName(
List<Object> navigationButtonsList,
String buttonNameToRemove,
String enumNameField
)
throws IllegalAccessException, NoSuchFieldException {
for (Object button : navigationButtonsList) {
Field f = button.getClass().getDeclaredField(enumNameField);
String currentButtonEnumName = (String) f.get(button);
if (buttonNameToRemove.equals(currentButtonEnumName)) {
navigationButtonsList.remove(button);
break;
}
}
return navigationButtonsList;
}
}

View File

@@ -0,0 +1,30 @@
package app.revanced.extension.instagram.misc.links;
import android.net.Uri;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
@SuppressWarnings("unused")
public final class OpenLinksExternallyPatch {
/**
* Injection point.
*/
public static boolean openExternally(String url) {
try {
// The "url" parameter to this function will be of the form.
// https://l.instagram.com/?u=<actual url>&e=<tracking id>
String actualUrl = Uri.parse(url).getQueryParameter("u");
if (actualUrl != null) {
Utils.openLink(actualUrl);
return true;
}
} catch (Exception ex) {
Logger.printException(() -> "openExternally failure", ex);
}
return false;
}
}

View File

@@ -0,0 +1,15 @@
package app.revanced.extension.instagram.misc.privacy;
import app.revanced.extension.shared.privacy.LinkSanitizer;
@SuppressWarnings("unused")
public final class SanitizeSharingLinksPatch {
private static final LinkSanitizer sanitizer = new LinkSanitizer("igsh");
/**
* Injection point.
*/
public static String sanitizeSharingLink(String url) {
return sanitizer.sanitizeUrlString(url);
}
}

View File

@@ -0,0 +1,33 @@
package app.revanced.extension.instagram.misc.share.domain;
import android.net.Uri;
import app.revanced.extension.shared.Logger;
@SuppressWarnings("unused")
public final class ChangeLinkSharingDomainPatch {
private static String getCustomShareDomain() {
// Method is modified during patching.
throw new IllegalStateException();
}
/**
* Injection point.
*/
public static String setCustomShareDomain(String url) {
try {
Uri uri = Uri.parse(url);
Uri.Builder builder = uri
.buildUpon()
.authority(getCustomShareDomain())
.clearQuery();
String patchedUrl = builder.build().toString();
Logger.printInfo(() -> "Domain change from : " + url + " to: " + patchedUrl);
return patchedUrl;
} catch (Exception ex) {
Logger.printException(() -> "setCustomShareDomain failure with " + url, ex);
return url;
}
}
}

View File

@@ -0,0 +1,15 @@
package app.revanced.extension.instagram.misc.share.privacy;
import app.revanced.extension.shared.privacy.LinkSanitizer;
@SuppressWarnings("unused")
public final class SanitizeSharingLinksPatch {
private static final LinkSanitizer sanitizer = new LinkSanitizer("igsh");
/**
* Injection point.
*/
public static String sanitizeSharingLink(String url) {
return sanitizer.sanitizeUrlString(url);
}
}

View File

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

View File

@@ -0,0 +1,17 @@
package app.revanced.extension.music.patches;
import app.revanced.extension.music.settings.Settings;
@SuppressWarnings("unused")
public class ForceOriginalAudioPatch {
/**
* Injection point.
*/
public static void setEnabled() {
app.revanced.extension.shared.patches.ForceOriginalAudioPatch.setEnabled(
Settings.FORCE_ORIGINAL_AUDIO.get(),
Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get()
);
}
}

View File

@@ -0,0 +1,49 @@
package app.revanced.extension.music.patches;
import static app.revanced.extension.shared.Utils.hideViewBy0dpUnderCondition;
import android.view.View;
import android.view.ViewGroup;
import app.revanced.extension.music.settings.Settings;
@SuppressWarnings("unused")
public class HideButtonsPatch {
/**
* 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, view);
}
/**
* Injection point
*/
public static boolean hideHistoryButton(boolean original) {
return original && !Settings.HIDE_HISTORY_BUTTON.get();
}
/**
* Injection point
*/
public static void hideNotificationButton(View view) {
if (view.getParent() instanceof ViewGroup viewGroup) {
hideViewBy0dpUnderCondition(Settings.HIDE_NOTIFICATION_BUTTON, viewGroup);
}
}
/**
* Injection point
*/
public static void hideSearchButton(View view) {
hideViewBy0dpUnderCondition(Settings.HIDE_SEARCH_BUTTON, view);
}
}

View File

@@ -0,0 +1,18 @@
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 HideCategoryBarPatch {
/**
* Injection point
*/
public static void hideCategoryBar(View view) {
hideViewBy0dpUnderCondition(Settings.HIDE_CATEGORY_BAR, view);
}
}

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

@@ -1,10 +1,14 @@
package app.revanced.extension.music.patches.spoof; 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_NO_SDK;
import static app.revanced.extension.shared.spoof.ClientType.ANDROID_VR_1_43_32; 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.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; import app.revanced.extension.shared.spoof.ClientType;
import app.revanced.extension.shared.spoof.requests.StreamingDataRequest;
@SuppressWarnings("unused") @SuppressWarnings("unused")
public class SpoofVideoStreamsPatch { public class SpoofVideoStreamsPatch {
@@ -13,11 +17,14 @@ public class SpoofVideoStreamsPatch {
* Injection point. * Injection point.
*/ */
public static void setClientOrderToUse() { public static void setClientOrderToUse() {
ClientType[] availableClients = { List<ClientType> availableClients = List.of(
ANDROID_VR_1_43_32, ANDROID_VR_1_43_32,
ANDROID_VR_1_61_48, ANDROID_NO_SDK,
}; VISIONOS,
ANDROID_VR_1_61_48
);
StreamingDataRequest.setClientOrderToUse(availableClients, ANDROID_VR_1_43_32); app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch.setClientsToUse(
availableClients, SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get());
} }
} }

View File

@@ -0,0 +1,27 @@
package app.revanced.extension.music.patches.theme;
import app.revanced.extension.shared.theme.BaseThemePatch;
@SuppressWarnings("unused")
public class ThemePatch extends BaseThemePatch {
// Color constants used in relation with litho components.
private static final int[] DARK_VALUES = {
0xFF212121, // Comments box background.
0xFF030303, // Button container background in album.
0xFF000000, // Button container background in playlist.
};
/**
* Injection point.
* <p>
* Change the color of Litho components.
* If the color of the component matches one of the values, return the background color.
*
* @param originalValue The original color value.
* @return The new or original color value.
*/
public static int getValue(int originalValue) {
return processColorValue(originalValue, DARK_VALUES, null);
}
}

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,41 @@
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, true);
public static final BooleanSetting HIDE_CATEGORY_BAR = new BooleanSetting("revanced_music_hide_category_bar", FALSE, true);
public static final BooleanSetting HIDE_HISTORY_BUTTON = new BooleanSetting("revanced_music_hide_history_button", FALSE, true);
public static final BooleanSetting HIDE_SEARCH_BUTTON = new BooleanSetting("revanced_music_hide_search_button", FALSE, true);
public static final BooleanSetting HIDE_NOTIFICATION_BUTTON = new BooleanSetting("revanced_music_hide_notification_button", 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 CHANGE_MINIPLAYER_COLOR = new BooleanSetting("revanced_music_change_miniplayer_color", FALSE, true);
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));
public static final BooleanSetting FORCE_ORIGINAL_AUDIO = new BooleanSetting("revanced_force_original_audio", TRUE, true);
}

View File

@@ -0,0 +1,93 @@
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.GmsCoreSupport;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.BaseSettings;
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);
// Clunky work around until preferences are custom classes that manage themselves.
// Custom branding only works with non-root install. But the preferences must be
// added during patched because of difficulties detecting during patching if it's
// a root install. So instead the non-functional preferences are removed during
// runtime if the app is mount (root) installation.
if (GmsCoreSupport.isPackageNameOriginal()) {
removePreferences(
BaseSettings.CUSTOM_BRANDING_ICON.key,
BaseSettings.CUSTOM_BRANDING_NAME.key);
}
} 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

@@ -16,6 +16,7 @@ import java.util.Arrays;
import app.revanced.extension.shared.Logger; import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils; import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.ui.Dim;
import com.amazon.video.sdk.player.Player; import com.amazon.video.sdk.player.Player;
@@ -64,9 +65,8 @@ public class PlaybackSpeedPatch {
SpeedIconDrawable speedIcon = new SpeedIconDrawable(); SpeedIconDrawable speedIcon = new SpeedIconDrawable();
speedButton.setImageDrawable(speedIcon); speedButton.setImageDrawable(speedIcon);
int buttonSize = Utils.dipToPixels(48); speedButton.setMinimumWidth(Dim.dp48);
speedButton.setMinimumWidth(buttonSize); speedButton.setMinimumHeight(Dim.dp48);
speedButton.setMinimumHeight(buttonSize);
return speedButton; return speedButton;
} }
@@ -197,11 +197,11 @@ class SpeedIconDrawable extends Drawable {
@Override @Override
public int getIntrinsicWidth() { public int getIntrinsicWidth() {
return Utils.dipToPixels(32); return Dim.dp32;
} }
@Override @Override
public int getIntrinsicHeight() { public int getIntrinsicHeight() {
return Utils.dipToPixels(32); return Dim.dp32;
} }
} }

View File

@@ -0,0 +1,4 @@
dependencies {
compileOnly(project(":extensions:shared:library"))
compileOnly(project(":extensions:samsung:radio:stub"))
}

View File

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

View File

@@ -0,0 +1,24 @@
package app.revanced.extension.samsung.radio.misc.fix.crash;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@SuppressWarnings("unused")
public final class FixCrashPatch {
/**
* Injection point.
* <p>
* Add the required permissions to the request list to avoid crashes on API 34+.
**/
public static final String[] fixPermissionRequestList(String[] perms) {
List<String> permsList = new ArrayList<>(Arrays.asList(perms));
if (permsList.contains("android.permission.POST_NOTIFICATIONS")) {
permsList.addAll(Arrays.asList("android.permission.RECORD_AUDIO", "android.permission.READ_PHONE_STATE", "android.permission.FOREGROUND_SERVICE_MICROPHONE"));
}
if (permsList.contains("android.permission.RECORD_AUDIO")) {
permsList.add("android.permission.FOREGROUND_SERVICE_MICROPHONE");
}
return permsList.toArray(new String[0]);
}
}

View File

@@ -0,0 +1,19 @@
package app.revanced.extension.samsung.radio.restrictions.device;
import android.os.SemSystemProperties;
import java.util.Arrays;
@SuppressWarnings("unused")
public final class BypassDeviceChecksPatch {
/**
* Injection point.
* <p>
* Check if the device has the required hardware
**/
public static final boolean checkIfDeviceIsIncompatible(String[] deviceList) {
String currentDevice = SemSystemProperties.getSalesCode();
return Arrays.asList(deviceList).contains(currentDevice);
}
}

View File

@@ -0,0 +1,17 @@
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "app.revanced.extension"
compileSdk = 34
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}

View File

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

View File

@@ -0,0 +1,7 @@
package android.os;
public class SemSystemProperties {
public static String getSalesCode() {
throw new UnsupportedOperationException("Stub");
}
}

View File

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

View File

@@ -27,12 +27,10 @@ import java.util.Locale;
import app.revanced.extension.shared.requests.Requester; import app.revanced.extension.shared.requests.Requester;
import app.revanced.extension.shared.requests.Route; import app.revanced.extension.shared.requests.Route;
import app.revanced.extension.shared.ui.CustomDialog;
@SuppressWarnings("unused") @SuppressWarnings("unused")
public class GmsCoreSupport { public class GmsCoreSupport {
private static final String PACKAGE_NAME_YOUTUBE = "com.google.android.youtube";
private static final String PACKAGE_NAME_YOUTUBE_MUSIC = "com.google.android.apps.youtube.music";
private static final String GMS_CORE_PACKAGE_NAME private static final String GMS_CORE_PACKAGE_NAME
= getGmsCoreVendorGroupId() + ".android.gms"; = getGmsCoreVendorGroupId() + ".android.gms";
private static final Uri GMS_CORE_PROVIDER private static final Uri GMS_CORE_PROVIDER
@@ -52,6 +50,20 @@ public class GmsCoreSupport {
@Nullable @Nullable
private static volatile Boolean DONT_KILL_MY_APP_MANUFACTURER_SUPPORTED; private static volatile Boolean DONT_KILL_MY_APP_MANUFACTURER_SUPPORTED;
private static String getOriginalPackageName() {
return null; // Modified during patching.
}
/**
* @return If the current package name is the same as the original unpatched app.
* If `GmsCore support` was not included during patching, this returns true;
*/
public static boolean isPackageNameOriginal() {
String originalPackageName = getOriginalPackageName();
return originalPackageName == null
|| originalPackageName.equals(Utils.getContext().getPackageName());
}
private static void open(String queryOrLink) { private static void open(String queryOrLink) {
Logger.printInfo(() -> "Opening link: " + queryOrLink); Logger.printInfo(() -> "Opening link: " + queryOrLink);
@@ -80,17 +92,17 @@ public class GmsCoreSupport {
// Otherwise, if device is in dark mode the dialog is shown with wrong color scheme. // Otherwise, if device is in dark mode the dialog is shown with wrong color scheme.
Utils.runOnMainThreadDelayed(() -> { Utils.runOnMainThreadDelayed(() -> {
// Create the custom dialog. // Create the custom dialog.
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog( Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context, context,
str("gms_core_dialog_title"), // Title. str("gms_core_dialog_title"), // Title.
str(dialogMessageRef), // Message. str(dialogMessageRef), // Message.
null, // No EditText. null, // No EditText.
str(positiveButtonTextRef), // OK button text. str(positiveButtonTextRef), // OK button text.
() -> onPositiveClickListener.onClick(null, 0), // Convert DialogInterface.OnClickListener to Runnable. () -> onPositiveClickListener.onClick(null, 0), // Convert DialogInterface.OnClickListener to Runnable.
null, // No Cancel button action. null, // No Cancel button action.
null, // No Neutral button text. null, // No Neutral button text.
null, // No Neutral button action. null, // No Neutral button action.
true // Dismiss dialog when onNeutralClick. true // Dismiss dialog when onNeutralClick.
); );
Dialog dialog = dialogPair.first; Dialog dialog = dialogPair.first;
@@ -112,11 +124,10 @@ public class GmsCoreSupport {
// Verify the user has not included GmsCore for a root installation. // Verify the user has not included GmsCore for a root installation.
// GmsCore Support changes the package name, but with a mounted installation // GmsCore Support changes the package name, but with a mounted installation
// all manifest changes are ignored and the original package name is used. // all manifest changes are ignored and the original package name is used.
String packageName = context.getPackageName(); if (isPackageNameOriginal()) {
if (packageName.equals(PACKAGE_NAME_YOUTUBE) || packageName.equals(PACKAGE_NAME_YOUTUBE_MUSIC)) {
Logger.printInfo(() -> "App is mounted with root, but GmsCore patch was included"); Logger.printInfo(() -> "App is mounted with root, but GmsCore patch was included");
// Cannot use localize text here, since the app will load // Cannot use localize text here, since the app will load resources
// resources from the unpatched app and all patch strings are missing. // from the unpatched app and all patch strings are missing.
Utils.showToastLong("The 'GmsCore support' patch breaks mount installations"); Utils.showToastLong("The 'GmsCore support' patch breaks mount installations");
// Do not exit. If the app exits before launch completes (and without // Do not exit. If the app exits before launch completes (and without
@@ -249,8 +260,7 @@ public class GmsCoreSupport {
}; };
} }
// Modified by a patch. Do not touch.
private static String getGmsCoreVendorGroupId() { private static String getGmsCoreVendorGroupId() {
return "app.revanced"; return "app.revanced"; // Modified during patching.
} }
} }

View File

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

View File

@@ -4,6 +4,8 @@ import android.annotation.SuppressLint;
import android.app.Activity; import android.app.Activity;
import android.app.Dialog; import android.app.Dialog;
import android.app.DialogFragment; import android.app.DialogFragment;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.pm.ApplicationInfo; import android.content.pm.ApplicationInfo;
@@ -12,10 +14,8 @@ import android.content.pm.PackageManager;
import android.content.res.Configuration; import android.content.res.Configuration;
import android.content.res.Resources; import android.content.res.Resources;
import android.graphics.Color; 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.net.ConnectivityManager;
import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
@@ -23,12 +23,7 @@ import android.os.Looper;
import android.preference.Preference; import android.preference.Preference;
import android.preference.PreferenceGroup; import android.preference.PreferenceGroup;
import android.preference.PreferenceScreen; 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.Pair;
import android.util.TypedValue;
import android.view.Gravity; import android.view.Gravity;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@@ -37,21 +32,15 @@ import android.view.Window;
import android.view.WindowManager; import android.view.WindowManager;
import android.view.animation.Animation; import android.view.animation.Animation;
import android.view.animation.AnimationUtils; 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.Toast;
import android.widget.Toolbar;
import androidx.annotation.ColorInt; import androidx.annotation.ColorInt;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import java.text.Bidi; import java.text.Bidi;
import java.text.Collator;
import java.text.Normalizer;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@@ -68,7 +57,9 @@ import app.revanced.extension.shared.settings.AppLanguage;
import app.revanced.extension.shared.settings.BaseSettings; import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.settings.BooleanSetting; import app.revanced.extension.shared.settings.BooleanSetting;
import app.revanced.extension.shared.settings.preference.ReVancedAboutPreference; import app.revanced.extension.shared.settings.preference.ReVancedAboutPreference;
import app.revanced.extension.shared.ui.Dim;
@SuppressWarnings("NewApi")
public class Utils { public class Utils {
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
@@ -85,6 +76,15 @@ public class Utils {
@Nullable @Nullable
private static Boolean isDarkModeEnabled; private static Boolean isDarkModeEnabled;
// Cached Collator instance with its locale.
@Nullable
private static Locale cachedCollatorLocale;
@Nullable
private static Collator cachedCollator;
private static final Pattern PUNCTUATION_PATTERN = Pattern.compile("\\p{P}+");
private static final Pattern DIACRITICS_PATTERN = Pattern.compile("\\p{M}");
private Utils() { private Utils() {
} // utility class } // utility class
@@ -116,7 +116,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() { public static String getAppVersionName() {
if (versionName == null) { if (versionName == null) {
@@ -278,41 +278,63 @@ public class Utils {
* @return zero, if the resource is not found. * @return zero, if the resource is not found.
*/ */
@SuppressLint("DiscouragedApi") @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()); 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. * @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 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 { 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 { public static Animation getResourceAnimation(String resourceIdentifierName) throws Resources.NotFoundException {
return AnimationUtils.loadAnimation(getContext(), getResourceIdentifier(resourceIdentifierName, "anim")); return AnimationUtils.loadAnimation(getContext(), getResourceIdentifierOrThrow(resourceIdentifierName, "anim"));
} }
@ColorInt @ColorInt
public static int getResourceColor(String resourceIdentifierName) throws Resources.NotFoundException { public static int getResourceColor(String resourceIdentifierName) throws Resources.NotFoundException {
//noinspection deprecation //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 { 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 { 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 { 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> { public interface MatchFilter<T> {
@@ -323,13 +345,9 @@ public class Utils {
* Includes sub children. * Includes sub children.
*/ */
public static <R extends View> R getChildViewByResourceName(View view, String str) { public static <R extends View> R getChildViewByResourceName(View view, String str) {
var child = view.findViewById(Utils.getResourceIdentifier(str, "id")); var child = view.findViewById(Utils.getResourceIdentifierOrThrow(str, "id"));
if (child != null) { //noinspection unchecked
//noinspection unchecked return (R) child;
return (R) child;
}
throw new IllegalArgumentException("View with resource name not found: " + str);
} }
/** /**
@@ -415,9 +433,9 @@ public class Utils {
} }
public static void setClipboard(CharSequence text) { public static void setClipboard(CharSequence text) {
android.content.ClipboardManager clipboard = (android.content.ClipboardManager) context ClipboardManager clipboard = (ClipboardManager) context
.getSystemService(Context.CLIPBOARD_SERVICE); .getSystemService(Context.CLIPBOARD_SERVICE);
android.content.ClipData clip = android.content.ClipData.newPlainText("ReVanced", text); ClipData clip = ClipData.newPlainText("ReVanced", text);
clipboard.setPrimaryClip(clip); clipboard.setPrimaryClip(clip);
} }
@@ -577,7 +595,13 @@ public class Utils {
showToast(messageToToast, Toast.LENGTH_LONG); 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); Objects.requireNonNull(messageToToast);
runOnMainThreadNowOrLater(() -> { runOnMainThreadNowOrLater(() -> {
Context currentContext = context; Context currentContext = context;
@@ -679,6 +703,18 @@ public class Utils {
} }
} }
public static void openLink(String url) {
try {
Intent intent = new Intent("android.intent.action.VIEW", Uri.parse(url));
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
Logger.printInfo(() -> "Opening link with external browser: " + intent);
getContext().startActivity(intent);
} catch (Exception ex) {
Logger.printException(() -> "openLink failure", ex);
}
}
public enum NetworkType { public enum NetworkType {
NONE, NONE,
MOBILE, MOBILE,
@@ -719,436 +755,51 @@ public class Utils {
} }
/** /**
* Hide a view by setting its layout params to 0x0 * Hides a view by setting its layout width and height to 0dp.
* @param view The view to hide. * Handles null layout params safely.
*
* @param view The view to hide. If null, does nothing.
*/ */
public static void hideViewByLayoutParams(View view) { public static void hideViewByLayoutParams(@Nullable View view) {
if (view instanceof LinearLayout) { if (view == null) return;
LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(0, 0);
view.setLayoutParams(layoutParams); ViewGroup.LayoutParams params = view.getLayoutParams();
} else if (view instanceof FrameLayout) {
FrameLayout.LayoutParams layoutParams2 = new FrameLayout.LayoutParams(0, 0); if (params == null) {
view.setLayoutParams(layoutParams2); // Create generic 0x0 layout params accepted by all ViewGroups.
} else if (view instanceof RelativeLayout) { params = new ViewGroup.LayoutParams(0, 0);
RelativeLayout.LayoutParams layoutParams3 = new RelativeLayout.LayoutParams(0, 0);
view.setLayoutParams(layoutParams3);
} else if (view instanceof Toolbar) {
Toolbar.LayoutParams layoutParams4 = new Toolbar.LayoutParams(0, 0);
view.setLayoutParams(layoutParams4);
} else if (view instanceof ViewGroup) {
ViewGroup.LayoutParams layoutParams5 = new ViewGroup.LayoutParams(0, 0);
view.setLayoutParams(layoutParams5);
} else { } else {
ViewGroup.LayoutParams params = view.getLayoutParams();
params.width = 0; params.width = 0;
params.height = 0; params.height = 0;
view.setLayoutParams(params);
} }
view.setLayoutParams(params);
} }
/** /**
* Creates a custom dialog with a styled layout, including a title, message, buttons, and an * Configures the parameters of a dialog window, including its width, gravity, vertical offset and background dimming.
* optional EditText. The dialog's appearance adapts to the app's dark mode setting, with * The width is calculated as a percentage of the screen's portrait width and the vertical offset is specified in DIP.
* rounded corners and customizable button actions. Buttons adjust dynamically to their text * The default dialog background is removed to allow for custom styling.
* 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 window The {@link Window} object to configure.
* @param title Title text of the dialog. * @param gravity The gravity for positioning the dialog (e.g., {@link Gravity#BOTTOM}).
* @param message Message text of the dialog (supports Spanned for HTML), or null if replaced by EditText. * @param yOffsetDip The vertical offset from the gravity position in DIP.
* @param editText EditText to include in the dialog, or null if no EditText is needed. * @param widthPercentage The width of the dialog as a percentage of the screen's portrait width (0-100).
* @param okButtonText OK button text, or null to use the default "OK" string. * @param dimAmount If true, sets the background dim amount to 0 (no dimming); if false, leaves the default dim amount.
* @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.
*/ */
@SuppressWarnings("ExtractMethodRecommender") public static void setDialogWindowParameters(Window window, int gravity, int yOffsetDip, int widthPercentage, boolean dimAmount) {
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) {
WindowManager.LayoutParams params = window.getAttributes(); WindowManager.LayoutParams params = window.getAttributes();
DisplayMetrics displayMetrics = Resources.getSystem().getDisplayMetrics(); params.width = Dim.pctPortraitWidth(widthPercentage);
int portraitWidth = (int) (displayMetrics.widthPixels * 0.9);
if (Resources.getSystem().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
portraitWidth = (int) Math.min(portraitWidth, displayMetrics.heightPixels * 0.9);
}
params.width = portraitWidth;
params.height = WindowManager.LayoutParams.WRAP_CONTENT; params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.gravity = Gravity.CENTER; params.gravity = gravity;
window.setAttributes(params); params.y = yOffsetDip > 0 ? Dim.dp(yOffsetDip) : 0;
window.setBackgroundDrawable(null); // Remove default dialog background. if (dimAmount) {
} params.dimAmount = 0f;
}
/** window.setAttributes(params); // Apply window attributes.
* Adds a styled button to a dialog's button container with customizable text, click behavior, and appearance. window.setBackgroundDrawable(null); // Remove default dialog background
* 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;
}
/**
* Creates an array of corner radii for a rounded rectangle shape.
*
* @param dp Radius in density-independent pixels (dip) to apply to all corners.
* @return An array of eight float values representing the corner radii
* (top-left, top-right, bottom-right, bottom-left).
*/
public static float[] createCornerRadii(float dp) {
final float radius = dipToPixels(dp);
return new float[]{radius, radius, radius, radius, radius, radius, radius, radius};
} }
/** /**
@@ -1310,30 +961,60 @@ public class Utils {
} }
} }
private static final Pattern punctuationPattern = Pattern.compile("\\p{P}+");
/** /**
* Strips all punctuation and converts to lower case. A null parameter returns an empty string. * Removes punctuation and converts text to lowercase. Returns an empty string if input is null.
*/ */
public static String removePunctuationToLowercase(@Nullable CharSequence original) { public static String removePunctuationToLowercase(@Nullable CharSequence original) {
if (original == null) return ""; if (original == null) return "";
return punctuationPattern.matcher(original).replaceAll("") return PUNCTUATION_PATTERN.matcher(original).replaceAll("")
.toLowerCase(BaseSettings.REVANCED_LANGUAGE.get().getLocale()); .toLowerCase(BaseSettings.REVANCED_LANGUAGE.get().getLocale());
} }
/** /**
* Sort a PreferenceGroup and all it's sub groups by title or key. * Normalizes text for search: applies NFD, removes diacritics, and lowercases (locale-neutral).
* Returns an empty string if input is null.
*/
public static String normalizeTextToLowercase(@Nullable CharSequence original) {
if (original == null) return "";
return DIACRITICS_PATTERN.matcher(Normalizer.normalize(original, Normalizer.Form.NFD))
.replaceAll("").toLowerCase(Locale.ROOT);
}
/**
* Returns a cached Collator for the current locale, or creates a new one if locale changed.
*/
private static Collator getCollator() {
Locale currentLocale = BaseSettings.REVANCED_LANGUAGE.get().getLocale();
if (cachedCollator == null || !currentLocale.equals(cachedCollatorLocale)) {
cachedCollatorLocale = currentLocale;
cachedCollator = Collator.getInstance(currentLocale);
cachedCollator.setStrength(Collator.SECONDARY); // Case-insensitive, diacritic-insensitive.
}
return cachedCollator;
}
/**
* Sorts a {@link PreferenceGroup} and all nested subgroups by title or key.
* <p>
* The sort order is controlled by the {@link Sort} suffix present in the preference key.
* Preferences without a key or without a {@link Sort} suffix remain in their original order.
* <p>
* Sorting is performed using {@link Collator} with the current user locale,
* ensuring correct alphabetical ordering for all supported languages
* (e.g., Ukrainian "і", German "ß", French accented characters, etc.).
* *
* Sort order is determined by the preferences key {@link Sort} suffix. * @param group the {@link PreferenceGroup} to sort
*
* If a preference has no key or no {@link Sort} suffix,
* then the preferences are left unsorted.
*/ */
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
public static void sortPreferenceGroups(PreferenceGroup group) { public static void sortPreferenceGroups(PreferenceGroup group) {
Sort groupSort = Sort.fromKey(group.getKey(), Sort.UNSORTED); Sort groupSort = Sort.fromKey(group.getKey(), Sort.UNSORTED);
List<Pair<String, Preference>> preferences = new ArrayList<>(); List<Pair<String, Preference>> preferences = new ArrayList<>();
// Get cached Collator for locale-aware string comparison.
Collator collator = getCollator();
for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) { for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) {
Preference preference = group.getPreference(i); Preference preference = group.getPreference(i);
@@ -1364,10 +1045,11 @@ public class Utils {
preferences.add(new Pair<>(sortValue, preference)); preferences.add(new Pair<>(sortValue, preference));
} }
//noinspection ComparatorCombinators // Sort the list using locale-specific collation rules.
Collections.sort(preferences, (pair1, pair2) Collections.sort(preferences, (pair1, pair2)
-> pair1.first.compareTo(pair2.first)); -> collator.compare(pair1.first, pair2.first));
// Reassign order values to reflect the new sorted sequence
int index = 0; int index = 0;
for (Pair<String, Preference> pair : preferences) { for (Pair<String, Preference> pair : preferences) {
int order = index++; int order = index++;
@@ -1388,7 +1070,7 @@ public class Utils {
* Set all preferences to multiline titles if the device is not using an English variant. * 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 * The English strings are heavily scrutinized and all titles fit on screen
* except 2 or 3 preference strings and those do not affect readability. * 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, * 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. * and visually it looks better to clip the text and keep all titles 1 line.
*/ */
@@ -1424,42 +1106,6 @@ public class Utils {
return getResourceColor(colorString); return getResourceColor(colorString);
} }
/**
* Converts dip value to actual device pixels.
*
* @param dip The density-independent pixels value.
* @return The device pixel value.
*/
public static int dipToPixels(float dip) {
return (int) TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dip,
Resources.getSystem().getDisplayMetrics()
);
}
/**
* Converts a percentage of the screen height to actual device pixels.
*
* @param percentage The percentage of the screen height (e.g., 30 for 30%).
* @return The device pixel value corresponding to the percentage of screen height.
*/
public static int percentageHeightToPixels(int percentage) {
DisplayMetrics metrics = context.getResources().getDisplayMetrics();
return (int) (metrics.heightPixels * (percentage / 100.0f));
}
/**
* Converts a percentage of the screen width to actual device pixels.
*
* @param percentage The percentage of the screen width (e.g., 30 for 30%).
* @return The device pixel value corresponding to the percentage of screen width.
*/
public static int percentageWidthToPixels(int percentage) {
DisplayMetrics metrics = context.getResources().getDisplayMetrics();
return (int) (metrics.widthPixels * (percentage / 100.0f));
}
/** /**
* Uses {@link #adjustColorBrightness(int, float)} depending if light or dark mode is active. * Uses {@link #adjustColorBrightness(int, float)} depending if light or dark mode is active.
*/ */
@@ -1497,9 +1143,9 @@ public class Utils {
blue = Math.round(blue + (255 - blue) * t); blue = Math.round(blue + (255 - blue) * t);
} else { } else {
// Darken or no change: Scale toward black. // Darken or no change: Scale toward black.
red *= factor; red = Math.round(red * factor);
green *= factor; green = Math.round(green * factor);
blue *= factor; blue = Math.round(blue * factor);
} }
// Ensure values are within [0, 255]. // 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 android.text.Html.FROM_HTML_MODE_COMPACT;
import static app.revanced.extension.shared.StringRef.str; import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.shared.Utils.DialogFragmentOnStartAction; import static app.revanced.extension.shared.Utils.DialogFragmentOnStartAction;
import static app.revanced.extension.shared.Utils.dipToPixels;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Activity; import android.app.Activity;
@@ -26,6 +25,7 @@ import java.util.Collection;
import app.revanced.extension.shared.Logger; import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils; import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.BaseSettings; import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.ui.CustomDialog;
abstract class Check { abstract class Check {
private static final int NUMBER_OF_TIMES_TO_IGNORE_WARNING_BEFORE_DISABLING = 2; private static final int NUMBER_OF_TIMES_TO_IGNORE_WARNING_BEFORE_DISABLING = 2;
@@ -93,7 +93,7 @@ abstract class Check {
Utils.runOnMainThreadDelayed(() -> { Utils.runOnMainThreadDelayed(() -> {
// Create the custom dialog. // Create the custom dialog.
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog( Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
activity, activity,
str("revanced_check_environment_failed_title"), // Title. str("revanced_check_environment_failed_title"), // Title.
message, // Message. message, // Message.
@@ -127,7 +127,8 @@ abstract class Check {
// Add icon to the dialog. // Add icon to the dialog.
ImageView iconView = new ImageView(activity); 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.setColorFilter(Utils.getAppForegroundColor(), PorterDuff.Mode.SRC_IN);
iconView.setPadding(0, 0, 0, 0); iconView.setPadding(0, 0, 0, 0);
LinearLayout.LayoutParams iconParams = new LinearLayout.LayoutParams( LinearLayout.LayoutParams iconParams = new LinearLayout.LayoutParams(
@@ -158,8 +159,8 @@ abstract class Check {
Button ignoreButton; Button ignoreButton;
// Check if buttons are in a single-row layout (buttonContainer has one child: rowContainer). // Check if buttons are in a single-row layout (buttonContainer has one child: rowContainer).
if (buttonContainer.getChildCount() == 1 && buttonContainer.getChildAt(0) instanceof LinearLayout) { if (buttonContainer.getChildCount() == 1
LinearLayout rowContainer = (LinearLayout) buttonContainer.getChildAt(0); && buttonContainer.getChildAt(0) instanceof LinearLayout rowContainer) {
// Neutral button is the first child (index 0). // Neutral button is the first child (index 0).
ignoreButton = (Button) rowContainer.getChildAt(0); ignoreButton = (Button) rowContainer.getChildAt(0);
// OK button is the last child. // OK button is the last child.

View File

@@ -1,4 +1,4 @@
package app.revanced.extension.youtube.patches; package app.revanced.extension.shared.patches;
import static app.revanced.extension.shared.StringRef.str; import static app.revanced.extension.shared.StringRef.str;
@@ -13,7 +13,8 @@ import java.net.UnknownHostException;
import app.revanced.extension.shared.Logger; import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils; import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.settings.Settings; import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.ui.CustomDialog;
@SuppressWarnings("unused") @SuppressWarnings("unused")
public class CheckWatchHistoryDomainNameResolutionPatch { public class CheckWatchHistoryDomainNameResolutionPatch {
@@ -45,10 +46,10 @@ public class CheckWatchHistoryDomainNameResolutionPatch {
/** /**
* Injection point. * Injection point.
* *
* Checks if s.youtube.com is blacklisted and playback history will fail to work. * Checks if YouTube watch history endpoint cannot be reached.
*/ */
public static void checkDnsResolver(Activity context) { public static void checkDnsResolver(Activity context) {
if (!Utils.isNetworkConnected() || !Settings.CHECK_WATCH_HISTORY_DOMAIN_NAME.get()) return; if (!Utils.isNetworkConnected() || !BaseSettings.CHECK_WATCH_HISTORY_DOMAIN_NAME.get()) return;
Utils.runOnBackgroundThread(() -> { Utils.runOnBackgroundThread(() -> {
try { try {
@@ -60,34 +61,30 @@ public class CheckWatchHistoryDomainNameResolutionPatch {
// Prevent this false positive by verify youtube.com resolves. // Prevent this false positive by verify youtube.com resolves.
// If youtube.com does not resolve, then it's not a watch history domain resolving error // If youtube.com does not resolve, then it's not a watch history domain resolving error
// because the entire app will not work since no domains are resolving. // because the entire app will not work since no domains are resolving.
if (domainResolvesToValidIP(HISTORY_TRACKING_ENDPOINT) String domainYouTube = "youtube.com";
|| !domainResolvesToValidIP("youtube.com")) { if (!domainResolvesToValidIP(domainYouTube)
|| domainResolvesToValidIP(HISTORY_TRACKING_ENDPOINT)
// Check multiple times, so a false positive from a flaky connection is almost impossible.
|| !domainResolvesToValidIP(domainYouTube)
|| domainResolvesToValidIP(HISTORY_TRACKING_ENDPOINT)) {
return; return;
} }
Utils.runOnMainThread(() -> { Utils.runOnMainThread(() -> {
try { Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
// Create the custom dialog. context,
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog( str("revanced_check_watch_history_domain_name_dialog_title"), // Title.
context, Html.fromHtml(str("revanced_check_watch_history_domain_name_dialog_message")), // Message (HTML).
str("revanced_check_watch_history_domain_name_dialog_title"), // Title. null, // No EditText.
Html.fromHtml(str("revanced_check_watch_history_domain_name_dialog_message")), // Message (HTML). null, // OK button text.
null, // No EditText. () -> {}, // OK button action (just dismiss).
null, // OK button text. null, // No cancel button.
() -> {}, // OK button action (just dismiss). str("revanced_check_watch_history_domain_name_dialog_ignore"), // Neutral button text.
() -> {}, // Cancel button action (just dismiss). () -> BaseSettings.CHECK_WATCH_HISTORY_DOMAIN_NAME.save(false), // Neutral button action (Ignore).
str("revanced_check_watch_history_domain_name_dialog_ignore"), // Neutral button text. true // Dismiss dialog on Neutral button click.
() -> Settings.CHECK_WATCH_HISTORY_DOMAIN_NAME.save(false), // Neutral button action (Ignore). );
true // Dismiss dialog on Neutral button click.
);
// Show the dialog. Utils.showDialog(context, dialogPair.first, false, null);
Dialog dialog = dialogPair.first;
Utils.showDialog(context, dialog, false, null);
} catch (Exception ex) {
Logger.printException(() -> "checkDnsResolver dialog creation failure", ex);
}
}); });
} catch (Exception ex) { } catch (Exception ex) {
Logger.printException(() -> "checkDnsResolver failure", ex); Logger.printException(() -> "checkDnsResolver failure", ex);

View File

@@ -0,0 +1,179 @@
package app.revanced.extension.shared.patches;
import android.app.Notification;
import android.content.ComponentName;
import android.content.Context;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.view.View;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import app.revanced.extension.shared.GmsCoreSupport;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.BaseSettings;
/**
* Patch shared by YouTube and YT Music.
*/
@SuppressWarnings("unused")
public class CustomBrandingPatch {
// Important: In the future, additional branding themes can be added but all existing and prior
// themes cannot be removed or renamed.
//
// This is because if a user has a branding theme selected, then only that launch alias is enabled.
// If a future update removes or renames that alias, then after updating the app is effectively
// broken and it cannot be opened and not even clearing the app data will fix it.
// In that situation the only fix is to completely uninstall and reinstall again.
//
// The most that can be done is to hide a theme from the UI and keep the alias with dummy data.
public enum BrandingTheme {
/**
* Original unpatched icon.
*/
ORIGINAL,
ROUNDED,
MINIMAL,
SCALED,
/**
* User provided custom icon.
*/
CUSTOM;
private String packageAndNameIndexToClassAlias(String packageName, int appIndex) {
if (appIndex <= 0) {
throw new IllegalArgumentException("App index starts at index 1");
}
return packageName + ".revanced_" + name().toLowerCase(Locale.US) + '_' + appIndex;
}
}
private static final int notificationSmallIcon;
static {
BrandingTheme branding = BaseSettings.CUSTOM_BRANDING_ICON.get();
if (branding == BrandingTheme.ORIGINAL) {
notificationSmallIcon = 0;
} else {
// Original icon is quantum_ic_video_youtube_white_24
String iconName = "revanced_notification_icon";
if (branding == BrandingTheme.CUSTOM) {
iconName += "_custom";
}
notificationSmallIcon = Utils.getResourceIdentifier(iconName, "drawable");
if (notificationSmallIcon == 0) {
Logger.printException(() -> "Could not load notification small icon");
}
}
}
/**
* Injection point.
*/
public static View getLottieViewOrNull(View lottieStartupView) {
if (BaseSettings.CUSTOM_BRANDING_ICON.get() == BrandingTheme.ORIGINAL) {
return lottieStartupView;
}
return null;
}
/**
* Injection point.
*/
public static void setNotificationIcon(Notification.Builder builder) {
try {
if (notificationSmallIcon != 0) {
builder.setSmallIcon(notificationSmallIcon)
.setColor(Color.TRANSPARENT); // Remove YT red tint.
}
} catch (Exception ex) {
Logger.printException(() -> "setNotificationIcon failure", ex);
}
}
/**
* Injection point.
*
* The total number of app name aliases, including dummy aliases.
*/
private static int numberOfPresetAppNames() {
// Modified during patching.
throw new IllegalStateException();
}
/**
* Injection point.
*/
@SuppressWarnings("ConstantConditions")
public static void setBranding() {
try {
if (GmsCoreSupport.isPackageNameOriginal()) {
Logger.printInfo(() -> "App is root mounted. Cannot dynamically change app icon");
return;
}
Context context = Utils.getContext();
PackageManager pm = context.getPackageManager();
String packageName = context.getPackageName();
BrandingTheme selectedBranding = BaseSettings.CUSTOM_BRANDING_ICON.get();
final int selectedNameIndex = BaseSettings.CUSTOM_BRANDING_NAME.get();
ComponentName componentToEnable = null;
ComponentName defaultComponent = null;
List<ComponentName> componentsToDisable = new ArrayList<>();
for (BrandingTheme theme : BrandingTheme.values()) {
// Must always update all aliases including custom alias (last index).
final int numberOfPresetAppNames = numberOfPresetAppNames();
// App name indices starts at 1.
for (int index = 1; index <= numberOfPresetAppNames; index++) {
String aliasClass = theme.packageAndNameIndexToClassAlias(packageName, index);
ComponentName component = new ComponentName(packageName, aliasClass);
if (defaultComponent == null) {
// Default is always the first alias.
defaultComponent = component;
}
if (index == selectedNameIndex && theme == selectedBranding) {
componentToEnable = component;
} else {
componentsToDisable.add(component);
}
}
}
if (componentToEnable == null) {
// User imported a bad app name index value. Either the imported data
// was corrupted, or they previously had custom name enabled and the app
// no longer has a custom name specified.
Utils.showToastLong("Custom branding reset");
BaseSettings.CUSTOM_BRANDING_ICON.resetToDefault();
BaseSettings.CUSTOM_BRANDING_NAME.resetToDefault();
componentToEnable = defaultComponent;
componentsToDisable.remove(defaultComponent);
}
for (ComponentName disable : componentsToDisable) {
pm.setComponentEnabledSetting(disable,
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
}
// Use info logging because if the alias status become corrupt the app cannot launch.
ComponentName componentToEnableFinal = componentToEnable;
Logger.printInfo(() -> "Enabling: " + componentToEnableFinal.getClassName());
pm.setComponentEnabledSetting(componentToEnable,
PackageManager.COMPONENT_ENABLED_STATE_ENABLED, 0);
} catch (Exception ex) {
Logger.printException(() -> "setBranding failure", ex);
}
}
}

View File

@@ -1,5 +1,9 @@
package app.revanced.extension.youtube.patches; package app.revanced.extension.shared.patches;
import static java.lang.Boolean.TRUE;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
@@ -21,12 +25,28 @@ public final class EnableDebuggingPatch {
? new ConcurrentHashMap<>(800, 0.5f, 1) ? new ConcurrentHashMap<>(800, 0.5f, 1)
: null; : null;
private static final Set<Long> DISABLED_FEATURE_FLAGS = parseFlags(BaseSettings.DISABLED_FEATURE_FLAGS.get());
// Log all disabled flags on app startup.
static {
if (LOG_FEATURE_FLAGS && !DISABLED_FEATURE_FLAGS.isEmpty()) {
StringBuilder sb = new StringBuilder("Disabled feature flags:\n");
for (Long flag : DISABLED_FEATURE_FLAGS) {
sb.append(" ").append(flag).append('\n');
}
Logger.printDebug(sb::toString);
}
}
/** /**
* Injection point. * Injection point.
*/ */
public static boolean isBooleanFeatureFlagEnabled(boolean value, Long flag) { public static boolean isBooleanFeatureFlagEnabled(boolean value, Long flag) {
if (LOG_FEATURE_FLAGS && value) { if (LOG_FEATURE_FLAGS && value) {
if (featureFlags.putIfAbsent(flag, true) == null) { if (DISABLED_FEATURE_FLAGS.contains(flag)) {
return false;
}
if (featureFlags.putIfAbsent(flag, TRUE) == null) {
Logger.printDebug(() -> "boolean feature is enabled: " + flag); Logger.printDebug(() -> "boolean feature is enabled: " + flag);
} }
} }
@@ -70,10 +90,44 @@ public final class EnableDebuggingPatch {
if (LOG_FEATURE_FLAGS && !defaultValue.equals(value)) { if (LOG_FEATURE_FLAGS && !defaultValue.equals(value)) {
if (featureFlags.putIfAbsent(flag, true) == null) { if (featureFlags.putIfAbsent(flag, true) == null) {
Logger.printDebug(() -> " string feature is enabled: " + flag Logger.printDebug(() -> " string feature is enabled: " + flag
+ " value: " + value + (defaultValue.isEmpty() ? "" : " default: " + defaultValue)); + " value: " + value + (defaultValue.isEmpty() ? "" : " default: " + defaultValue));
} }
} }
return value; return value;
} }
/**
* Get all logged feature flags.
* @return Set of all known flags
*/
public static Set<Long> getAllLoggedFlags() {
if (featureFlags != null) {
return new HashSet<>(featureFlags.keySet());
}
return new HashSet<>();
}
/**
* Public method for parsing flags.
* @param flags String containing newline-separated flag IDs
* @return Set of parsed flag IDs
*/
public static Set<Long> parseFlags(String flags) {
Set<Long> parsedFlags = new HashSet<>();
if (!flags.isBlank()) {
for (String flag : flags.split("\n")) {
String trimmedFlag = flag.trim();
if (trimmedFlag.isEmpty()) continue; // Skip empty lines.
try {
parsedFlags.add(Long.parseLong(trimmedFlag));
} catch (NumberFormatException e) {
Logger.printException(() -> "Invalid flag ID: " + flag);
}
}
}
return parsedFlags;
}
} }

View File

@@ -0,0 +1,71 @@
package app.revanced.extension.shared.patches;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.settings.AppLanguage;
import app.revanced.extension.shared.spoof.ClientType;
import app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch;
@SuppressWarnings("unused")
public class ForceOriginalAudioPatch {
private static final String DEFAULT_AUDIO_TRACKS_SUFFIX = ".4";
private static volatile boolean enabled;
public static void setEnabled(boolean isEnabled, ClientType client) {
enabled = isEnabled;
if (isEnabled && !client.useAuth && !client.supportsMultiAudioTracks) {
// 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.NB; // Norwegian Bokmal.
Logger.printDebug(() -> "Setting language override: " + override);
SpoofVideoStreamsPatch.setLanguageOverride(override);
}
}
/**
* Injection point.
*/
public static boolean ignoreDefaultAudioStream(boolean original) {
if (enabled) {
return false;
}
return original;
}
/**
* Injection point.
*/
public static boolean isDefaultAudioStream(boolean isDefault, String audioTrackId, String audioTrackDisplayName) {
try {
if (!enabled) {
return isDefault;
}
if (audioTrackId.isEmpty()) {
// Older app targets can have empty audio tracks and these might be placeholders.
// The real audio tracks are called after these.
return isDefault;
}
Logger.printDebug(() -> "default: " + String.format("%-5s", isDefault) + " id: "
+ String.format("%-8s", audioTrackId) + " name:" + audioTrackDisplayName);
final boolean isOriginal = audioTrackId.endsWith(DEFAULT_AUDIO_TRACKS_SUFFIX);
if (isOriginal) {
Logger.printDebug(() -> "Using audio: " + audioTrackId);
}
return isOriginal;
} catch (Exception ex) {
Logger.printException(() -> "isDefaultAudioStream failure", ex);
return isDefault;
}
}
}

View File

@@ -0,0 +1,31 @@
package app.revanced.extension.shared.patches;
import app.revanced.extension.shared.privacy.LinkSanitizer;
import app.revanced.extension.shared.settings.BaseSettings;
/**
* YouTube and YouTube Music.
*/
@SuppressWarnings("unused")
public final class SanitizeSharingLinksPatch {
private static final LinkSanitizer sanitizer = new LinkSanitizer(
"si",
"feature" // Old tracking parameter name, and may be obsolete.
);
/**
* Injection point.
*/
public static String sanitize(String url) {
if (BaseSettings.SANITIZE_SHARED_LINKS.get()) {
url = sanitizer.sanitizeUrlString(url);
}
if (BaseSettings.REPLACE_MUSIC_LINKS_WITH_YOUTUBE.get()) {
url = url.replace("music.youtube.com", "youtube.com");
}
return url;
}
}

View File

@@ -0,0 +1,68 @@
package app.revanced.extension.shared.privacy;
import android.net.Uri;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import app.revanced.extension.shared.Logger;
/**
* Strips away specific parameters from URLs.
*/
public class LinkSanitizer {
private final Collection<String> parametersToRemove;
public LinkSanitizer(String ... parametersToRemove) {
final int parameterCount = parametersToRemove.length;
// List is faster if only checking a few parameters.
this.parametersToRemove = parameterCount > 4
? Set.of(parametersToRemove)
: List.of(parametersToRemove);
}
public String sanitizeUrlString(String url) {
try {
return sanitizeUri(Uri.parse(url)).toString();
} catch (Exception ex) {
Logger.printException(() -> "sanitizeUrlString failure: " + url, ex);
return url;
}
}
public Uri sanitizeUri(Uri uri) {
try {
String scheme = uri.getScheme();
if (scheme == null || !(scheme.equals("http") || scheme.equals("https"))) {
// Opening YouTube share sheet 'other' option passes the video title as a URI.
// Checking !uri.isHierarchical() works for all cases, except if the
// video title starts with / and then it's hierarchical but still an invalid URI.
Logger.printDebug(() -> "Ignoring uri: " + uri);
return uri;
}
Uri.Builder builder = uri.buildUpon().clearQuery();
if (!parametersToRemove.isEmpty()) {
for (String paramName : uri.getQueryParameterNames()) {
if (!parametersToRemove.contains(paramName)) {
for (String value : uri.getQueryParameters(paramName)) {
builder.appendQueryParameter(paramName, value);
}
}
}
}
Uri sanitizedUrl = builder.build();
Logger.printInfo(() -> "Sanitized url: " + uri + " to: " + sanitizedUrl);
return sanitizedUrl;
} catch (Exception ex) {
Logger.printException(() -> "sanitizeUri failure: " + uri, ex);
return uri;
}
}
}

View File

@@ -36,8 +36,8 @@ public enum AppLanguage {
FR, FR,
GL, GL,
GU, GU,
HI,
HE, // App uses obsolete 'IW' and not the modern 'HE' ISO code. HE, // App uses obsolete 'IW' and not the modern 'HE' ISO code.
HI,
HR, HR,
HU, HU,
HY, HY,
@@ -60,9 +60,9 @@ public enum AppLanguage {
MR, MR,
MS, MS,
MY, MY,
NB,
NE, NE,
NL, NL,
NB,
OR, OR,
PA, PA,
PL, PL,

View File

@@ -0,0 +1,166 @@
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.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;
import app.revanced.extension.shared.ui.Dim;
/**
* 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);
toolbar.setTitleMarginStart(Dim.dp16);
toolbar.setTitleMarginEnd(Dim.dp16);
TextView toolbarTextView = Utils.getChildView(toolbar, false, view -> view instanceof TextView);
if (toolbarTextView != null) {
toolbarTextView.setTextColor(Utils.getAppForegroundColor());
toolbarTextView.setTextSize(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

@@ -2,11 +2,8 @@ package app.revanced.extension.shared.settings;
import static java.lang.Boolean.FALSE; import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE; import static java.lang.Boolean.TRUE;
import static app.revanced.extension.shared.patches.CustomBrandingPatch.BrandingTheme;
import static app.revanced.extension.shared.settings.Setting.parent; 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. * Settings shared across multiple apps.
@@ -28,11 +25,23 @@ public class BaseSettings {
*/ */
public static final BooleanSetting SHOW_MENU_ICONS = new BooleanSetting("revanced_show_menu_icons", TRUE, true); 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", "");
//
// Settings shared by YouTube and YouTube Music.
//
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 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_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()); public static final BooleanSetting SANITIZE_SHARED_LINKS = new BooleanSetting("revanced_sanitize_sharing_links", TRUE);
// Client type must be last spoof setting due to cyclic references. public static final BooleanSetting REPLACE_MUSIC_LINKS_WITH_YOUTUBE = new BooleanSetting("revanced_replace_music_with_youtube", FALSE);
public static final EnumSetting<ClientType> SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_video_streams_client_type", ClientType.ANDROID_VR_1_61_48, true, parent(SPOOF_VIDEO_STREAMS));
public static final BooleanSetting CHECK_WATCH_HISTORY_DOMAIN_NAME = new BooleanSetting("revanced_check_watch_history_domain_name", TRUE, false, false);
public static final EnumSetting<BrandingTheme> CUSTOM_BRANDING_ICON = new EnumSetting<>("revanced_custom_branding_icon", BrandingTheme.ORIGINAL, true);
public static final IntegerSetting CUSTOM_BRANDING_NAME = new IntegerSetting("revanced_custom_branding_name", 1, true);
public static final StringSetting DISABLED_FEATURE_FLAGS = new StringSetting("revanced_disabled_feature_flags", "", true, parent(DEBUG));
} }

View File

@@ -1,18 +1,27 @@
package app.revanced.extension.shared.settings; package app.revanced.extension.shared.settings;
import static app.revanced.extension.shared.StringRef.str;
import android.content.Context; import android.content.Context;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; 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.Logger;
import app.revanced.extension.shared.StringRef; import app.revanced.extension.shared.StringRef;
import app.revanced.extension.shared.Utils; import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.preference.SharedPrefCategory; 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> { public abstract class Setting<T> {
@@ -23,24 +32,66 @@ public abstract class Setting<T> {
*/ */
public interface Availability { public interface Availability {
boolean isAvailable(); 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. * Availability based on a single parent setting being enabled.
*/ */
public static Availability parent(BooleanSetting parent) { 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 a single parent setting being disabled.
*/
public static Availability parentNot(BooleanSetting parent) {
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. * Availability based on all parents being enabled.
*/ */
public static Availability parentsAll(BooleanSetting... parents) { public static Availability parentsAll(BooleanSetting... parents) {
return () -> { return new Availability() {
for (BooleanSetting parent : parents) { @Override
if (!parent.get()) return false; 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 +99,19 @@ public abstract class Setting<T> {
* Availability based on any parent being enabled. * Availability based on any parent being enabled.
*/ */
public static Availability parentsAny(BooleanSetting... parents) { public static Availability parentsAny(BooleanSetting... parents) {
return () -> { return new Availability() {
for (BooleanSetting parent : parents) { @Override
if (parent.get()) return true; 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 +171,7 @@ public abstract class Setting<T> {
* @return All settings that have been created, sorted by keys. * @return All settings that have been created, sorted by keys.
*/ */
private static List<Setting<?>> allLoadedSettingsSorted() { private static List<Setting<?>> allLoadedSettingsSorted() {
//noinspection ComparatorCombinators
Collections.sort(SETTINGS, (Setting<?> o1, Setting<?> o2) -> o1.key.compareTo(o2.key)); Collections.sort(SETTINGS, (Setting<?> o1, Setting<?> o2) -> o1.key.compareTo(o2.key));
return allLoadedSettings(); return allLoadedSettings();
} }
@@ -207,9 +267,7 @@ public abstract class Setting<T> {
SETTINGS.add(this); SETTINGS.add(this);
if (PATH_TO_SETTINGS.put(key, this) != null) { if (PATH_TO_SETTINGS.put(key, this) != null) {
// Debug setting may not be created yet so using Logger may cause an initialization crash. Logger.printException(() -> this.getClass().getSimpleName()
// Show a toast instead.
Utils.showToastLong(this.getClass().getSimpleName()
+ " error: Duplicate Setting key found: " + key); + " error: Duplicate Setting key found: " + key);
} }
@@ -231,10 +289,10 @@ public abstract class Setting<T> {
/** /**
* Migrate an old Setting value previously stored in a different SharedPreference. * Migrate an old Setting value previously stored in a different SharedPreference.
* * <p>
* This method will be deleted in the future. * This method will be deleted in the future.
*/ */
@SuppressWarnings("rawtypes") @SuppressWarnings({"rawtypes", "NewApi"})
public static void migrateFromOldPreferences(SharedPrefCategory oldPrefs, Setting setting, String settingKey) { public static void migrateFromOldPreferences(SharedPrefCategory oldPrefs, Setting setting, String settingKey) {
if (!oldPrefs.preferences.contains(settingKey)) { if (!oldPrefs.preferences.contains(settingKey)) {
return; // Nothing to do. return; // Nothing to do.
@@ -254,7 +312,7 @@ public abstract class Setting<T> {
migratedValue = oldPrefs.getString(settingKey, (String) newValue); migratedValue = oldPrefs.getString(settingKey, (String) newValue);
} else { } else {
Logger.printException(() -> "Unknown setting: " + setting); 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(); oldPrefs.preferences.edit().remove(settingKey).apply();
return; return;
} }
@@ -273,7 +331,7 @@ public abstract class Setting<T> {
/** /**
* Sets, but does _not_ persistently save the value. * Sets, but does _not_ persistently save the value.
* This method is only to be used by the Settings preference code. * This method is only to be used by the Settings preference code.
* * <p>
* This intentionally is a static method to deter * This intentionally is a static method to deter
* accidental usage when {@link #save(Object)} was intended. * accidental usage when {@link #save(Object)} was intended.
*/ */
@@ -349,6 +407,17 @@ public abstract class Setting<T> {
return availability == null || availability.isAvailable(); return availability == null || availability.isAvailable();
} }
/**
* Get the parent Settings that this setting depends on.
* @return List of parent Settings, or empty list if no dependencies exist.
* Defensive: handles null availability or missing getParentSettings() override.
*/
public List<Setting<?>> getParentSettings() {
return availability == null
? Collections.emptyList()
: Objects.requireNonNullElse(availability.getParentSettings(), Collections.emptyList());
}
/** /**
* @return if the currently set value is the same as {@link #defaultValue} * @return if the currently set value is the same as {@link #defaultValue}
*/ */
@@ -467,9 +536,12 @@ public abstract class Setting<T> {
callback.settingsImported(alertDialogContext); callback.settingsImported(alertDialogContext);
} }
Utils.showToastLong(numberOfSettingsImported == 0 // Use a delay, otherwise the toast can move about on screen from the dismissing dialog.
? str("revanced_settings_import_reset") final int numberOfSettingsImportedFinal = numberOfSettingsImported;
: str("revanced_settings_import_success", numberOfSettingsImported)); Utils.runOnMainThreadDelayed(() -> Utils.showToastLong(numberOfSettingsImportedFinal == 0
? str("revanced_settings_import_reset")
: str("revanced_settings_import_success", numberOfSettingsImportedFinal)),
150);
return rebootSettingChanged; return rebootSettingChanged;
} catch (JSONException | IllegalArgumentException ex) { } 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.BaseSettings;
import app.revanced.extension.shared.settings.BooleanSetting; import app.revanced.extension.shared.settings.BooleanSetting;
import app.revanced.extension.shared.settings.Setting; import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.shared.ui.CustomDialog;
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
public abstract class AbstractPreferenceFragment extends PreferenceFragment { public abstract class AbstractPreferenceFragment extends PreferenceFragment {
@@ -52,7 +53,7 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
* Set by subclasses if Strings cannot be added as a resource. * Set by subclasses if Strings cannot be added as a resource.
*/ */
@Nullable @Nullable
protected static String restartDialogButtonText, restartDialogTitle, confirmDialogTitle, restartDialogMessage; protected static CharSequence restartDialogTitle, restartDialogMessage, restartDialogButtonText, confirmDialogTitle;
private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> { private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> {
try { try {
@@ -124,10 +125,13 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
showingUserDialogMessage = true; showingUserDialogMessage = true;
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog( CharSequence message = BulletPointPreference.formatIntoBulletPoints(
Objects.requireNonNull(setting.userDialogMessage).toString());
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context, context,
confirmDialogTitle, // Title. confirmDialogTitle, // Title.
Objects.requireNonNull(setting.userDialogMessage).toString(), // No message. message,
null, // No EditText. null, // No EditText.
null, // OK button text. null, // OK button text.
() -> { () -> {
@@ -151,6 +155,7 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
); );
dialogPair.first.setOnDismissListener(d -> showingUserDialogMessage = false); dialogPair.first.setOnDismissListener(d -> showingUserDialogMessage = false);
dialogPair.first.setCancelable(false);
// Show the dialog. // Show the dialog.
dialogPair.first.show(); dialogPair.first.show();
@@ -248,7 +253,8 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
Setting.privateSetValueFromString(setting, listPref.getValue()); Setting.privateSetValueFromString(setting, listPref.getValue());
} }
updateListPreferenceSummary(listPref, setting); 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); Logger.printException(() -> "Setting cannot be handled: " + pref.getClass() + ": " + pref);
} }
} }
@@ -302,7 +308,8 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
restartDialogButtonText = str("revanced_settings_restart"); restartDialogButtonText = str("revanced_settings_restart");
} }
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(context, Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context,
restartDialogTitle, // Title. restartDialogTitle, // Title.
restartDialogMessage, // Message. restartDialogMessage, // Message.
null, // No EditText. null, // No EditText.

View File

@@ -0,0 +1,86 @@
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 {
/**
* Replaces bullet points with styled spans.
*/
public static CharSequence formatIntoBulletPoints(CharSequence source) {
final char bulletPoint = '•';
if (TextUtils.indexOf(source, bulletPoint) < 0) {
return source; // Nothing to do.
}
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) == bulletPoint) {
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.content.Context;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.preference.Preference; import android.preference.Preference;
import app.revanced.extension.shared.settings.preference.LogBufferManager;
/** /**
* A custom preference that clears the ReVanced debug log buffer when clicked. * A custom preference that clears the ReVanced debug log buffer when clicked.

View File

@@ -1,8 +1,7 @@
package app.revanced.extension.shared.settings.preference; package app.revanced.extension.shared.settings.preference;
import static app.revanced.extension.shared.StringRef.str; import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.shared.Utils.getResourceIdentifier; import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
import static app.revanced.extension.shared.Utils.dipToPixels;
import android.app.Dialog; import android.app.Dialog;
import android.content.Context; import android.content.Context;
@@ -13,20 +12,20 @@ import android.os.Bundle;
import android.preference.EditTextPreference; import android.preference.EditTextPreference;
import android.text.Editable; import android.text.Editable;
import android.text.InputType; import android.text.InputType;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextWatcher; import android.text.TextWatcher;
import android.text.style.ForegroundColorSpan;
import android.text.style.RelativeSizeSpan;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.util.Pair; import android.util.Pair;
import android.view.Gravity;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.ViewParent; 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.ColorInt;
import androidx.annotation.Nullable;
import java.util.Locale; import java.util.Locale;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -35,6 +34,9 @@ import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils; import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.Setting; import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.shared.settings.StringSetting; import app.revanced.extension.shared.settings.StringSetting;
import app.revanced.extension.shared.ui.ColorDot;
import app.revanced.extension.shared.ui.CustomDialog;
import app.revanced.extension.shared.ui.Dim;
/** /**
* A custom preference for selecting a color via a hexadecimal code or a color picker dialog. * 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"}) @SuppressWarnings({"unused", "deprecation"})
public class ColorPickerPreference extends EditTextPreference { 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;
/** /** Matches everything that is not a hex number/letter. */
* 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.
*/
private static final Pattern PATTERN_NOT_HEX = Pattern.compile("[^0-9A-Fa-f]"); private static final Pattern PATTERN_NOT_HEX = Pattern.compile("[^0-9A-Fa-f]");
/** /** Alpha for dimming when the preference is disabled. */
* Alpha for dimming when the preference is disabled. public static final float DISABLED_ALPHA = 0.5f; // 50%
*/
private 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; private View widgetColorDot;
/** /** Dialog View displaying a colored dot for the selected color preview in the dialog. */
* Current color in RGB format (without alpha). private View dialogColorDot;
*/
/** Current color, including alpha channel if opacity slider is enabled. */
@ColorInt @ColorInt
private int currentColor; private int currentColor;
/** /** Associated setting for storing the color value. */
* Associated setting for storing the color value.
*/
private StringSetting colorSetting; 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; private TextWatcher colorTextWatcher;
/** /** Dialog color picker view. */
* Dialog TextView displaying a colored dot for the selected color preview in the dialog. protected ColorPickerView dialogColorPickerView;
*/
private TextView dialogColorPreview;
/** /** Listener for color changes. */
* Dialog color picker view. protected OnColorChangeListener colorChangeListener;
*/
private ColorPickerView dialogColorPickerView; /** 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, * Removes non valid hex characters, converts to all uppercase,
* and adds # character to the start if not present. * and adds # character to the start if not present.
*/ */
public static String cleanupColorCodeString(String colorString) { public static String cleanupColorCodeString(String colorString, boolean includeAlpha) {
// Remove non-hex chars, convert to uppercase, and ensure correct length
String result = "#" + PATTERN_NOT_HEX.matcher(colorString) String result = "#" + PATTERN_NOT_HEX.matcher(colorString)
.replaceAll("").toUpperCase(Locale.ROOT); .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;
} }
return result.substring(0, COLOR_STRING_LENGTH); return result.substring(0, maxLength);
} }
/** /**
* @param color RGB color, without an alpha channel. * @param color Color, with or without alpha channel.
* @return #RRGGBB hex color string * @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) { public static String getColorString(@ColorInt int color, boolean includeAlpha) {
String colorString = String.format("#%06X", color); if (includeAlpha) {
if ((color & 0xFF000000) != 0) { return String.format("#%08X", color);
// Likely a bug somewhere.
Logger.printException(() -> "getColorString: color has alpha channel: " + colorString);
} }
return colorString; color = color & 0x00FFFFFF; // Mask to strip alpha.
return String.format("#%06X", color);
} }
/** /**
* Creates a Spanned object for a colored dot using SpannableString. * Interface for notifying color changes.
*
* @param color The RGB color (without alpha).
* @return A Spanned object with the colored dot.
*/ */
public static Spanned getColorDot(@ColorInt int color) { public interface OnColorChangeListener {
SpannableString spannable = new SpannableString(COLOR_DOT_STRING); void onColorChanged(String key, int newColor);
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); * Sets the listener for color changes.
return spannable; */
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) { 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. * Initializes the preference by setting up the EditText, loading the color, and set the widget layout.
*/ */
private void init() { private void init() {
colorSetting = (StringSetting) Setting.getSettingFromPath(getKey()); if (getKey() != null) {
if (colorSetting == null) { colorSetting = (StringSetting) Setting.getSettingFromPath(getKey());
Logger.printException(() -> "Could not find color setting for: " + 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(); EditText editText = getEditText();
@@ -171,27 +175,29 @@ public class ColorPickerPreference extends EditTextPreference {
} }
// Set the widget layout to a custom layout containing the colored dot. // 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. * 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 @Override
public final void setText(String colorString) { public void setText(String colorString) {
try { try {
Logger.printDebug(() -> "setText: " + colorString); Logger.printDebug(() -> "setText: " + colorString);
super.setText(colorString); super.setText(colorString);
currentColor = Color.parseColor(colorString) & 0x00FFFFFF; currentColor = Color.parseColor(colorString);
if (colorSetting != null) { if (colorSetting != null) {
colorSetting.save(getColorString(currentColor)); colorSetting.save(getColorString(currentColor, opacitySliderEnabled));
} }
updateColorPreview(); updateDialogColorDot();
updateWidgetColorDot(); updateWidgetColorDot();
// Notify the listener about the color change.
if (colorChangeListener != null) {
colorChangeListener.onColorChanged(getKey(), currentColor);
}
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
// This code is reached if the user pastes settings json with an invalid color // This code is reached if the user pastes settings json with an invalid color
// since this preference is updated with the new setting text. // 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. * 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) { private TextWatcher createColorTextWatcher(ColorPickerView colorPickerView) {
return new TextWatcher() { return new TextWatcher() {
@@ -250,15 +226,16 @@ public class ColorPickerPreference extends EditTextPreference {
public void afterTextChanged(Editable edit) { public void afterTextChanged(Editable edit) {
try { try {
String colorString = edit.toString(); String colorString = edit.toString();
String sanitizedColorString = cleanupColorCodeString(colorString, opacitySliderEnabled);
String sanitizedColorString = cleanupColorCodeString(colorString);
if (!sanitizedColorString.equals(colorString)) { if (!sanitizedColorString.equals(colorString)) {
edit.replace(0, colorString.length(), sanitizedColorString); edit.replace(0, colorString.length(), sanitizedColorString);
return; return;
} }
if (sanitizedColorString.length() != COLOR_STRING_LENGTH) { int expectedLength = opacitySliderEnabled
// User is still typing out the color. ? COLOR_STRING_LENGTH_WITH_ALPHA
: COLOR_STRING_LENGTH_WITHOUT_ALPHA;
if (sanitizedColorString.length() != expectedLength) {
return; return;
} }
@@ -266,7 +243,7 @@ public class ColorPickerPreference extends EditTextPreference {
if (currentColor != newColor) { if (currentColor != newColor) {
Logger.printDebug(() -> "afterTextChanged: " + sanitizedColorString); Logger.printDebug(() -> "afterTextChanged: " + sanitizedColorString);
currentColor = newColor; currentColor = newColor;
updateColorPreview(); updateDialogColorDot();
updateWidgetColorDot(); updateWidgetColorDot();
colorPickerView.setColor(newColor); colorPickerView.setColor(newColor);
} }
@@ -279,32 +256,65 @@ 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 @Override
protected void showDialog(Bundle state) { protected void showDialog(Bundle state) {
Context context = getContext(); 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. // Inflate color picker view.
View colorPicker = LayoutInflater.from(context).inflate( View colorPicker = LayoutInflater.from(context).inflate(LAYOUT_REVANCED_COLOR_PICKER, null);
getResourceIdentifier("revanced_color_picker", "layout"), null); dialogColorPickerView = colorPicker.findViewById(ID_REVANCED_COLOR_PICKER_VIEW);
dialogColorPickerView = colorPicker.findViewById( dialogColorPickerView.setOpacitySliderEnabled(opacitySliderEnabled);
getResourceIdentifier("revanced_color_picker_view", "id"));
dialogColorPickerView.setColor(currentColor); dialogColorPickerView.setColor(currentColor);
contentContainer.addView(colorPicker);
// Horizontal layout for preview and EditText. // Horizontal layout for preview and EditText.
LinearLayout inputLayout = new LinearLayout(context); LinearLayout inputLayout = new LinearLayout(context);
inputLayout.setOrientation(LinearLayout.HORIZONTAL); 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 previewParams = new LinearLayout.LayoutParams(Dim.dp20,Dim.dp20);
LinearLayout.LayoutParams.WRAP_CONTENT, previewParams.setMargins(Dim.dp16, 0, Dim.dp10, 0);
LinearLayout.LayoutParams.WRAP_CONTENT dialogColorDot.setLayoutParams(previewParams);
); inputLayout.addView(dialogColorDot);
previewParams.setMargins(dipToPixels(15), 0, dipToPixels(10), 0); // text dot has its own indents so 15, instead 16. updateDialogColorDot();
dialogColorPreview.setLayoutParams(previewParams);
inputLayout.addView(dialogColorPreview);
updateColorPreview();
EditText editText = getEditText(); EditText editText = getEditText();
ViewParent parent = editText.getParent(); ViewParent parent = editText.getParent();
@@ -315,7 +325,7 @@ public class ColorPickerPreference extends EditTextPreference {
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT LinearLayout.LayoutParams.WRAP_CONTENT
)); ));
String currentColorString = getColorString(currentColor); String currentColorString = getColorString(currentColor, opacitySliderEnabled);
editText.setText(currentColorString); editText.setText(currentColorString);
editText.setSelection(currentColorString.length()); editText.setSelection(currentColorString.length());
editText.setTypeface(Typeface.MONOSPACE); editText.setTypeface(Typeface.MONOSPACE);
@@ -334,16 +344,12 @@ public class ColorPickerPreference extends EditTextPreference {
paddingView.setLayoutParams(params); paddingView.setLayoutParams(params);
inputLayout.addView(paddingView); 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); contentContainer.addView(inputLayout);
// Create ScrollView to wrap the content container. // Create ScrollView to wrap the content container.
ScrollView contentScrollView = new ScrollView(context); ScrollView contentScrollView = new ScrollView(context);
contentScrollView.setVerticalScrollBarEnabled(false); // Disable vertical scrollbar. contentScrollView.setVerticalScrollBarEnabled(false);
contentScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER); // Disable overscroll effect. contentScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER);
LinearLayout.LayoutParams scrollViewParams = new LinearLayout.LayoutParams( LinearLayout.LayoutParams scrollViewParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
0, 0,
@@ -352,51 +358,43 @@ public class ColorPickerPreference extends EditTextPreference {
contentScrollView.setLayoutParams(scrollViewParams); contentScrollView.setLayoutParams(scrollViewParams);
contentScrollView.addView(contentContainer); contentScrollView.addView(contentContainer);
// Create custom dialog. final int originalColor = currentColor;
final int originalColor = currentColor & 0x00FFFFFF; Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
context, context,
getTitle() != null ? getTitle().toString() : str("revanced_settings_color_picker_title"), // Title. getTitle() != null ? getTitle().toString() : str("revanced_settings_color_picker_title"),
null, // No message. null,
null, // No EditText. null,
null, // OK button text. null,
() -> { () -> { // OK button action.
// OK button action.
try { try {
String colorString = editText.getText().toString(); 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")); Utils.showToastShort(str("revanced_settings_color_invalid"));
setText(getColorString(originalColor)); setText(getColorString(originalColor, opacitySliderEnabled));
return; return;
} }
setText(colorString); setText(colorString);
onDialogOkClicked();
} catch (Exception ex) { } catch (Exception ex) {
// Should never happen due to a bad color string, // Should never happen due to a bad color string,
// since the text is validated and fixed while the user types. // since the text is validated and fixed while the user types.
Logger.printException(() -> "OK button failure", ex); Logger.printException(() -> "OK button failure", ex);
} }
}, },
() -> { () -> { // Cancel button action.
// Cancel button action.
try { try {
// Restore the original color. setText(getColorString(originalColor, opacitySliderEnabled));
setText(getColorString(originalColor));
} catch (Exception ex) { } catch (Exception ex) {
Logger.printException(() -> "Cancel button failure", ex); Logger.printException(() -> "Cancel button failure", ex);
} }
}, },
str("revanced_settings_reset_color"), // Neutral button text. str("revanced_settings_reset_color"), // Neutral button text.
() -> { this::onDialogNeutralClicked, // Neutral button action.
// Neutral button action. false // Do not dismiss dialog.
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.
); );
// Add the ScrollView to the dialog's main layout. // Add the ScrollView to the dialog's main layout.
@@ -412,13 +410,13 @@ public class ColorPickerPreference extends EditTextPreference {
return; return;
} }
String updatedColorString = getColorString(color); String updatedColorString = getColorString(color, opacitySliderEnabled);
Logger.printDebug(() -> "onColorChanged: " + updatedColorString); Logger.printDebug(() -> "onColorChanged: " + updatedColorString);
currentColor = color; currentColor = color;
editText.setText(updatedColorString); editText.setText(updatedColorString);
editText.setSelection(updatedColorString.length()); editText.setSelection(updatedColorString.length());
updateColorPreview(); updateDialogColorDot();
updateWidgetColorDot(); updateWidgetColorDot();
}); });
@@ -437,7 +435,7 @@ public class ColorPickerPreference extends EditTextPreference {
colorTextWatcher = null; colorTextWatcher = null;
} }
dialogColorPreview = null; dialogColorDot = null;
dialogColorPickerView = null; dialogColorPickerView = null;
} }
@@ -446,4 +444,32 @@ public class ColorPickerPreference extends EditTextPreference {
super.setEnabled(enabled); super.setEnabled(enabled);
updateWidgetColorDot(); 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

@@ -1,6 +1,5 @@
package app.revanced.extension.shared.settings.preference; package app.revanced.extension.shared.settings.preference;
import static app.revanced.extension.shared.Utils.dipToPixels;
import static app.revanced.extension.shared.settings.preference.ColorPickerPreference.getColorString; import static app.revanced.extension.shared.settings.preference.ColorPickerPreference.getColorString;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
@@ -21,59 +20,76 @@ import androidx.annotation.ColorInt;
import app.revanced.extension.shared.Logger; import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils; import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.ui.Dim;
/** /**
* 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. * This implementation is density-independent and responsive across different screen sizes and DPIs.
*
* <p> * <p>
* This view displays two main components for color selection: * This view displays three main components for color selection:
* <ul> * <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>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) * <li><b>Saturation-Value Selector:</b> A rectangular area above the hue bar that allows the user to select the
* components of the color based on the selected hue. * 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> * </ul>
*
* <p> * <p>
* The view uses {@link LinearGradient} and {@link ComposeShader} to create the color gradients for the hue bar and the * The view uses {@link LinearGradient} and {@link ComposeShader} to create the color gradients for the hue bar,
* saturation-value selector. It also uses {@link Paint} to draw the selectors (draggable handles). * opacity slider, and the saturation-value selector. It also uses {@link Paint} to draw the selectors (draggable handles).
*
* <p> * <p>
* The selected color can be retrieved using {@link #getColor()} and can be set using {@link #setColor(int)}. * 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. * An {@link OnColorChangedListener} can be registered to receive notifications when the selected color changes.
*/ */
public class ColorPickerView extends View { public class ColorPickerView extends View {
/** /**
* Interface definition for a callback to be invoked when the selected color changes. * Interface definition for a callback to be invoked when the selected color changes.
*/ */
public interface OnColorChangedListener { public interface OnColorChangedListener {
/** /**
* Called when the selected color has changed. * 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); 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); public static final float TOUCH_EXPANSION = Dim.dp20;
private static final float MARGIN_BETWEEN_AREAS = dipToPixels(24); /** Margin between different areas of the view (saturation-value selector, hue bar, and opacity slider). */
private static final float VIEW_PADDING = dipToPixels(16); private static final float MARGIN_BETWEEN_AREAS = Dim.dp24;
private static final float HUE_BAR_HEIGHT = dipToPixels(12);
private static final float HUE_CORNER_RADIUS = dipToPixels(6); /** Padding around the view. */
private static final float SELECTOR_RADIUS = dipToPixels(12); private static final float VIEW_PADDING = Dim.dp16;
/** Height of the hue bar. */
private static final float HUE_BAR_HEIGHT = Dim.dp12;
/** Height of the opacity slider. */
private static final float OPACITY_BAR_HEIGHT = Dim.dp12;
/** Corner radius for the hue bar. */
private static final float HUE_CORNER_RADIUS = Dim.dp6;
/** Corner radius for the opacity slider. */
private static final float OPACITY_CORNER_RADIUS = Dim.dp6;
/** Radius of the selector handles. */
private static final float SELECTOR_RADIUS = Dim.dp12;
/** Stroke width for the selector handle outlines. */
private static final float SELECTOR_STROKE_WIDTH = 8; 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. * 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; private static final float SELECTOR_FILL_RADIUS = SELECTOR_RADIUS - SELECTOR_STROKE_WIDTH / 2;
/** Thin dark outline stroke width for the selector rings. */ /** Thin dark outline stroke width for the selector rings. */
private static final float SELECTOR_EDGE_STROKE_WIDTH = 1; 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 = public static final float SELECTOR_EDGE_RADIUS =
SELECTOR_RADIUS + SELECTOR_STROKE_WIDTH / 2 + SELECTOR_EDGE_STROKE_WIDTH / 2; SELECTOR_RADIUS + SELECTOR_STROKE_WIDTH / 2 + SELECTOR_EDGE_STROKE_WIDTH / 2;
@@ -85,6 +101,7 @@ public class ColorPickerView extends View {
@ColorInt @ColorInt
private static final int SELECTOR_EDGE_COLOR = Color.parseColor("#CFCFCF"); 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]; private static final int[] HUE_COLORS = new int[361];
static { static {
for (int i = 0; i < 361; i++) { 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); 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); 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); private final Paint selectorPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
{ {
selectorPaint.setStrokeWidth(SELECTOR_STROKE_WIDTH); selectorPaint.setStrokeWidth(SELECTOR_STROKE_WIDTH);
@@ -104,6 +126,10 @@ public class ColorPickerView extends View {
/** Bounds of the hue bar. */ /** Bounds of the hue bar. */
private final RectF hueRect = new RectF(); private final RectF hueRect = new RectF();
/** Bounds of the opacity slider. */
private final RectF opacityRect = new RectF();
/** Bounds of the saturation-value selector. */ /** Bounds of the saturation-value selector. */
private final RectF saturationValueRect = new RectF(); private final RectF saturationValueRect = new RectF();
@@ -112,21 +138,35 @@ public class ColorPickerView extends View {
/** Current hue value (0-360). */ /** Current hue value (0-360). */
private float hue = 0f; private float hue = 0f;
/** Current saturation value (0-1). */ /** Current saturation value (0-1). */
private float saturation = 1f; private float saturation = 1f;
/** Current value (brightness) value (0-1). */ /** Current value (brightness) value (0-1). */
private float value = 1f; 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 @ColorInt
private int selectedColor; private int selectedColor;
/** Listener for color change events. */
private OnColorChangedListener colorChangedListener; 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; private boolean isDraggingHue;
/** Tracks if the saturation-value selector is being dragged. */
private boolean isDraggingSaturation; 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) { public ColorPickerView(Context context) {
super(context); super(context);
} }
@@ -139,12 +179,32 @@ public class ColorPickerView extends View {
super(context, attrs, defStyleAttr); 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 @Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
final float DESIRED_ASPECT_RATIO = 0.8f; // height = width * 0.8 final float DESIRED_ASPECT_RATIO = 0.8f; // height = width * 0.8
final int minWidth = Utils.dipToPixels(250); final int minWidth = Dim.dp(250);
final int minHeight = (int) (minWidth * DESIRED_ASPECT_RATIO) + (int) (HUE_BAR_HEIGHT + MARGIN_BETWEEN_AREAS); 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 width = resolveSize(minWidth, widthMeasureSpec);
int height = resolveSize(minHeight, heightMeasureSpec); int height = resolveSize(minHeight, heightMeasureSpec);
@@ -154,7 +214,8 @@ public class ColorPickerView extends View {
height = Math.max(height, minHeight); height = Math.max(height, minHeight);
// Adjust height to maintain desired aspect ratio if possible. // 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) { if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY) {
height = desiredHeight; height = desiredHeight;
} }
@@ -163,17 +224,16 @@ public class ColorPickerView extends View {
} }
/** /**
* Called when the size of the view changes. * Updates the view's layout when its size changes, recalculating bounds and shaders.
* This method calculates and sets the bounds of the hue bar and saturation-value selector.
* It also creates the necessary shaders for the gradients.
*/ */
@Override @Override
protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
super.onSizeChanged(width, height, oldWidth, 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 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. // Adjust rectangles to account for padding and density-independent dimensions.
saturationValueRect.set( saturationValueRect.set(
@@ -185,18 +245,28 @@ public class ColorPickerView extends View {
hueRect.set( hueRect.set(
VIEW_PADDING, 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, 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. // Update the shaders.
updateHueShader(); updateHueShader();
updateSaturationValueShader(); 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() { private void updateHueShader() {
LinearGradient hueShader = new LinearGradient( 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. * Updates the shader for the opacity slider to reflect the current RGB color with varying opacity.
* This method creates a combined shader that blends a saturation gradient with a value gradient. */
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() { private void updateSaturationValueShader() {
// Create a saturation-value gradient based on the current hue. // 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). // Create a linear gradient for the value (brightness) from white to black (vertical).
//noinspection ExtractMethodRecommender
LinearGradient valShader = new LinearGradient( LinearGradient valShader = new LinearGradient(
saturationValueRect.left, saturationValueRect.top, saturationValueRect.left, saturationValueRect.top,
saturationValueRect.left, saturationValueRect.bottom, saturationValueRect.left, saturationValueRect.bottom,
@@ -249,11 +339,7 @@ public class ColorPickerView extends View {
} }
/** /**
* Draws the color picker view on the canvas. * Draws the color picker components, including the saturation-value selector, hue bar, opacity slider, and their respective handles.
* 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.
*/ */
@Override @Override
protected void onDraw(Canvas canvas) { protected void onDraw(Canvas canvas) {
@@ -263,49 +349,67 @@ public class ColorPickerView extends View {
// Draw the hue bar. // Draw the hue bar.
canvas.drawRoundRect(hueRect, HUE_CORNER_RADIUS, HUE_CORNER_RADIUS, huePaint); 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 hueSelectorX = hueRect.left + (hue / 360f) * hueRect.width();
final float hueSelectorY = hueRect.centerY(); final float hueSelectorY = hueRect.centerY();
final float satSelectorX = saturationValueRect.left + saturation * saturationValueRect.width(); final float satSelectorX = saturationValueRect.left + saturation * saturationValueRect.width();
final float satSelectorY = saturationValueRect.top + (1 - value) * saturationValueRect.height(); 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; 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.setStyle(Paint.Style.FILL_AND_STROKE);
selectorPaint.setColor(hueHandleColor); selectorPaint.setColor(hueHandleColor);
canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_FILL_RADIUS, selectorPaint); canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_FILL_RADIUS, selectorPaint);
selectorPaint.setColor(selectedColor | 0xFF000000); selectorPaint.setColor(satHandleColor);
canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_FILL_RADIUS, selectorPaint); 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. // Draw white outlines for the handles.
selectorPaint.setColor(SELECTOR_OUTLINE_COLOR); selectorPaint.setColor(SELECTOR_OUTLINE_COLOR);
selectorPaint.setStyle(Paint.Style.STROKE); selectorPaint.setStyle(Paint.Style.STROKE);
selectorPaint.setStrokeWidth(SELECTOR_STROKE_WIDTH); selectorPaint.setStrokeWidth(SELECTOR_STROKE_WIDTH);
canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_RADIUS, selectorPaint); canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_RADIUS, selectorPaint);
canvas.drawCircle(satSelectorX, satSelectorY, 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. // Draw thin dark outlines for the handles at the outer edge of the white outline.
selectorPaint.setColor(SELECTOR_EDGE_COLOR); selectorPaint.setColor(SELECTOR_EDGE_COLOR);
selectorPaint.setStrokeWidth(SELECTOR_EDGE_STROKE_WIDTH); selectorPaint.setStrokeWidth(SELECTOR_EDGE_STROKE_WIDTH);
canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_EDGE_RADIUS, selectorPaint); canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_EDGE_RADIUS, selectorPaint);
canvas.drawCircle(satSelectorX, satSelectorY, 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. * Handles touch events to allow dragging of the hue, saturation-value, and opacity selectors.
* 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.
* *
* @param event The motion event. * @param event The motion event.
* @return True if the event was handled, false otherwise. * @return True if the event was handled, false otherwise.
*/ */
@SuppressLint("ClickableViewAccessibility") // performClick is not overridden, but not needed in this case. @SuppressLint("ClickableViewAccessibility")
@Override @Override
public boolean onTouchEvent(MotionEvent event) { public boolean onTouchEvent(MotionEvent event) {
try { try {
@@ -314,13 +418,19 @@ public class ColorPickerView extends View {
final int action = event.getAction(); final int action = event.getAction();
Logger.printDebug(() -> "onTouchEvent action: " + action + " x: " + x + " y: " + y); 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( RectF expandedHueRect = new RectF(
hueRect.left, hueRect.left,
hueRect.top - TOUCH_EXPANSION, hueRect.top - TOUCH_EXPANSION,
hueRect.right, hueRect.right,
hueRect.bottom + TOUCH_EXPANSION 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) { switch (action) {
case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_DOWN:
@@ -331,7 +441,10 @@ public class ColorPickerView extends View {
final float satSelectorX = saturationValueRect.left + saturation * saturationValueRect.width(); final float satSelectorX = saturationValueRect.left + saturation * saturationValueRect.width();
final float valSelectorY = saturationValueRect.top + (1 - value) * saturationValueRect.height(); 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( RectF hueHitRect = new RectF(
hueSelectorX - SELECTOR_RADIUS, hueSelectorX - SELECTOR_RADIUS,
hueSelectorY - SELECTOR_RADIUS, hueSelectorY - SELECTOR_RADIUS,
@@ -344,14 +457,23 @@ public class ColorPickerView extends View {
satSelectorX + SELECTOR_RADIUS, satSelectorX + SELECTOR_RADIUS,
valSelectorY + 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)) { if (hueHitRect.contains(x, y)) {
isDraggingHue = true; isDraggingHue = true;
updateHueFromTouch(x); updateHueFromTouch(x);
} else if (satValHitRect.contains(x, y)) { } else if (satValHitRect.contains(x, y)) {
isDraggingSaturation = true; isDraggingSaturation = true;
updateSaturationValueFromTouch(x, y); updateSaturationValueFromTouch(x, y);
} else if (opacitySliderEnabled && opacityHitRect.contains(x, y)) {
isDraggingOpacity = true;
updateOpacityFromTouch(x);
} else if (expandedHueRect.contains(x, y)) { } else if (expandedHueRect.contains(x, y)) {
// Handle touch within the expanded hue bar area. // Handle touch within the expanded hue bar area.
isDraggingHue = true; isDraggingHue = true;
@@ -359,6 +481,9 @@ public class ColorPickerView extends View {
} else if (saturationValueRect.contains(x, y)) { } else if (saturationValueRect.contains(x, y)) {
isDraggingSaturation = true; isDraggingSaturation = true;
updateSaturationValueFromTouch(x, y); updateSaturationValueFromTouch(x, y);
} else if (opacitySliderEnabled && expandedOpacityRect.contains(x, y)) {
isDraggingOpacity = true;
updateOpacityFromTouch(x);
} }
break; break;
@@ -368,6 +493,8 @@ public class ColorPickerView extends View {
updateHueFromTouch(x); updateHueFromTouch(x);
} else if (isDraggingSaturation) { } else if (isDraggingSaturation) {
updateSaturationValueFromTouch(x, y); updateSaturationValueFromTouch(x, y);
} else if (isDraggingOpacity) {
updateOpacityFromTouch(x);
} }
break; break;
@@ -375,6 +502,7 @@ public class ColorPickerView extends View {
case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_CANCEL:
isDraggingHue = false; isDraggingHue = false;
isDraggingSaturation = false; isDraggingSaturation = false;
isDraggingOpacity = false;
break; break;
} }
} catch (Exception ex) { } catch (Exception ex) {
@@ -385,9 +513,7 @@ public class ColorPickerView extends View {
} }
/** /**
* Updates the hue value based on touch position, clamping to valid range. * Updates the hue value based on a touch event.
*
* @param x The x-coordinate of the touch position.
*/ */
private void updateHueFromTouch(float x) { private void updateHueFromTouch(float x) {
// Clamp x to the hue rectangle bounds. // Clamp x to the hue rectangle bounds.
@@ -399,14 +525,12 @@ public class ColorPickerView extends View {
hue = updatedHue; hue = updatedHue;
updateSaturationValueShader(); updateSaturationValueShader();
updateOpacityShader();
updateSelectedColor(); updateSelectedColor();
} }
/** /**
* Updates saturation and value based on touch position, clamping to valid range. * Updates the saturation and value based on a touch event.
*
* @param x The x-coordinate of the touch position.
* @param y The y-coordinate of the touch position.
*/ */
private void updateSaturationValueFromTouch(float x, float y) { private void updateSaturationValueFromTouch(float x, float y) {
// Clamp x and y to the saturation-value rectangle bounds. // Clamp x and y to the saturation-value rectangle bounds.
@@ -421,14 +545,34 @@ public class ColorPickerView extends View {
} }
saturation = updatedSaturation; saturation = updatedSaturation;
value = updatedValue; value = updatedValue;
updateOpacityShader();
updateSelectedColor(); 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() { 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) { if (selectedColor != updatedColor) {
selectedColor = updatedColor; selectedColor = updatedColor;
@@ -444,19 +588,16 @@ public class ColorPickerView extends View {
} }
/** /**
* Sets the currently selected color. * Sets the selected color, updating the hue, saturation, value and opacity sliders accordingly.
*
* @param color The color to set in either ARGB or RGB format.
*/ */
public void setColor(@ColorInt int color) { public void setColor(@ColorInt int color) {
color &= 0x00FFFFFF;
if (selectedColor == color) { if (selectedColor == color) {
return; return;
} }
// Update the selected color. // Update the selected color.
selectedColor = color; selectedColor = color;
Logger.printDebug(() -> "setColor: " + getColorString(selectedColor)); Logger.printDebug(() -> "setColor: " + getColorString(selectedColor, opacitySliderEnabled));
// Convert the ARGB color to HSV values. // Convert the ARGB color to HSV values.
float[] hsv = new float[3]; float[] hsv = new float[3];
@@ -466,9 +607,11 @@ public class ColorPickerView extends View {
hue = hsv[0]; hue = hsv[0];
saturation = hsv[1]; saturation = hsv[1];
value = hsv[2]; value = hsv[2];
opacity = opacitySliderEnabled ? ((color >> 24) & 0xFF) / 255f : 1f;
// Update the saturation-value shader based on the new hue. // Update the saturation-value shader based on the new hue.
updateSaturationValueShader(); updateSaturationValueShader();
updateOpacityShader();
// Notify the listener if it's set. // Notify the listener if it's set.
if (colorChangedListener != null) { if (colorChangedListener != null) {
@@ -481,8 +624,6 @@ public class ColorPickerView extends View {
/** /**
* Gets the currently selected color. * Gets the currently selected color.
*
* @return The selected color in RGB format with no alpha channel.
*/ */
@ColorInt @ColorInt
public int getColor() { public int getColor() {
@@ -490,9 +631,7 @@ public class ColorPickerView extends View {
} }
/** /**
* Sets the listener to be notified when the selected color changes. * Sets a listener to be notified when the selected color changes.
*
* @param listener The listener to set.
*/ */
public void setOnColorChangedListener(OnColorChangedListener listener) { public void setOnColorChangedListener(OnColorChangedListener listener) {
colorChangedListener = 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; package app.revanced.extension.shared.settings.preference;
import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
import android.app.Dialog; import android.app.Dialog;
import android.content.Context; import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
@@ -9,18 +11,86 @@ import android.util.Pair;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; 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.NonNull;
import androidx.annotation.Nullable;
import app.revanced.extension.shared.Utils; 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"}) @SuppressWarnings({"unused", "deprecation"})
public class CustomDialogListPreference extends ListPreference { 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. * Custom ArrayAdapter to handle checkmark visibility.
*/ */
@@ -35,8 +105,10 @@ public class CustomDialogListPreference extends ListPreference {
final CharSequence[] entryValues; final CharSequence[] entryValues;
String selectedValue; String selectedValue;
public ListPreferenceArrayAdapter(Context context, int resource, CharSequence[] entries, public ListPreferenceArrayAdapter(Context context, int resource,
CharSequence[] entryValues, String selectedValue) { CharSequence[] entries,
CharSequence[] entryValues,
String selectedValue) {
super(context, resource, entries); super(context, resource, entries);
this.layoutResourceId = resource; this.layoutResourceId = resource;
this.entryValues = entryValues; this.entryValues = entryValues;
@@ -53,19 +125,16 @@ public class CustomDialogListPreference extends ListPreference {
LayoutInflater inflater = LayoutInflater.from(getContext()); LayoutInflater inflater = LayoutInflater.from(getContext());
view = inflater.inflate(layoutResourceId, parent, false); view = inflater.inflate(layoutResourceId, parent, false);
holder = new SubViewDataContainer(); holder = new SubViewDataContainer();
holder.checkIcon = view.findViewById(Utils.getResourceIdentifier( holder.checkIcon = view.findViewById(ID_REVANCED_CHECK_ICON);
"revanced_check_icon", "id")); holder.placeholder = view.findViewById(ID_REVANCED_CHECK_ICON_PLACEHOLDER);
holder.placeholder = view.findViewById(Utils.getResourceIdentifier( holder.itemText = view.findViewById(ID_REVANCED_ITEM_TEXT);
"revanced_check_icon_placeholder", "id"));
holder.itemText = view.findViewById(Utils.getResourceIdentifier(
"revanced_item_text", "id"));
view.setTag(holder); view.setTag(holder);
} else { } else {
holder = (SubViewDataContainer) view.getTag(); holder = (SubViewDataContainer) view.getTag();
} }
// Set text. CharSequence itemText = getItem(position);
holder.itemText.setText(getItem(position)); holder.itemText.setText(itemText);
holder.itemText.setTextColor(Utils.getAppForegroundColor()); holder.itemText.setTextColor(Utils.getAppForegroundColor());
// Show or hide checkmark and placeholder. // Show or hide checkmark and placeholder.
@@ -103,6 +172,9 @@ public class CustomDialogListPreference extends ListPreference {
protected void showDialog(Bundle state) { protected void showDialog(Bundle state) {
Context context = getContext(); Context context = getContext();
CharSequence[] entriesToShow = getEntriesForDialog();
CharSequence[] entryValues = getEntryValues();
// Create ListView. // Create ListView.
ListView listView = new ListView(context); ListView listView = new ListView(context);
listView.setId(android.R.id.list); listView.setId(android.R.id.list);
@@ -111,9 +183,9 @@ public class CustomDialogListPreference extends ListPreference {
// Create custom adapter for the ListView. // Create custom adapter for the ListView.
ListPreferenceArrayAdapter adapter = new ListPreferenceArrayAdapter( ListPreferenceArrayAdapter adapter = new ListPreferenceArrayAdapter(
context, context,
Utils.getResourceIdentifier("revanced_custom_list_item_checked", "layout"), LAYOUT_REVANCED_CUSTOM_LIST_ITEM_CHECKED,
getEntries(), entriesToShow,
getEntryValues(), entryValues,
getValue() getValue()
); );
listView.setAdapter(adapter); listView.setAdapter(adapter);
@@ -121,7 +193,6 @@ public class CustomDialogListPreference extends ListPreference {
// Set checked item. // Set checked item.
String currentValue = getValue(); String currentValue = getValue();
if (currentValue != null) { if (currentValue != null) {
CharSequence[] entryValues = getEntryValues();
for (int i = 0, length = entryValues.length; i < length; i++) { for (int i = 0, length = entryValues.length; i < length; i++) {
if (currentValue.equals(entryValues[i].toString())) { if (currentValue.equals(entryValues[i].toString())) {
listView.setItemChecked(i, true); listView.setItemChecked(i, true);
@@ -132,19 +203,23 @@ public class CustomDialogListPreference extends ListPreference {
} }
// Create the custom dialog without OK button. // Create the custom dialog without OK button.
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog( Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context, context,
getTitle() != null ? getTitle().toString() : "", getTitle() != null ? getTitle().toString() : "",
null, null,
null, null,
null, // No OK button text. null,
null, // No OK button action. null,
() -> {}, // Cancel button action (just dismiss). this::clearHighlightedEntriesForDialog, // Cancel button action.
null, null,
null, null,
true 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. // Add the ListView to the main layout.
LinearLayout mainLayout = dialogPair.second; LinearLayout mainLayout = dialogPair.second;
LinearLayout.LayoutParams listViewParams = new LinearLayout.LayoutParams( LinearLayout.LayoutParams listViewParams = new LinearLayout.LayoutParams(
@@ -156,16 +231,28 @@ public class CustomDialogListPreference extends ListPreference {
// Handle item click to select value and dismiss dialog. // Handle item click to select value and dismiss dialog.
listView.setOnItemClickListener((parent, view, position, id) -> { listView.setOnItemClickListener((parent, view, position, id) -> {
String selectedValue = getEntryValues()[position].toString(); String selectedValue = entryValues[position].toString();
if (callChangeListener(selectedValue)) { if (callChangeListener(selectedValue)) {
setValue(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.setSelectedValue(selectedValue);
adapter.notifyDataSetChanged(); adapter.notifyDataSetChanged();
} }
dialogPair.first.dismiss();
// Clear highlighted entries before closing.
clearHighlightedEntriesForDialog();
dialog.dismiss();
}); });
// Show the dialog. // 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.content.Context;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.preference.Preference; 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. * A custom preference that triggers exporting ReVanced debug logs to the clipboard when clicked.

View File

@@ -0,0 +1,623 @@
package app.revanced.extension.shared.settings.preference;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
import android.annotation.SuppressLint;
import android.app.Dialog;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.ShapeDrawable;
import android.graphics.drawable.shapes.RoundRectShape;
import android.preference.Preference;
import android.text.Editable;
import android.text.InputType;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.util.AttributeSet;
import android.util.Pair;
import android.util.SparseBooleanArray;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.Space;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.patches.EnableDebuggingPatch;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.ui.CustomDialog;
import app.revanced.extension.shared.ui.Dim;
/**
* A custom preference that opens a dialog for managing feature flags.
* Allows moving boolean flags between active and blocked states with advanced selection.
*/
@SuppressWarnings({"deprecation", "unused"})
public class FeatureFlagsManagerPreference extends Preference {
private static final int DRAWABLE_REVANCED_SETTINGS_SELECT_ALL =
getResourceIdentifierOrThrow("revanced_settings_select_all", "drawable");
private static final int DRAWABLE_REVANCED_SETTINGS_DESELECT_ALL =
getResourceIdentifierOrThrow("revanced_settings_deselect_all", "drawable");
private static final int DRAWABLE_REVANCED_SETTINGS_COPY_ALL =
getResourceIdentifierOrThrow("revanced_settings_copy_all", "drawable");
private static final int DRAWABLE_REVANCED_SETTINGS_ARROW_RIGHT_ONE =
getResourceIdentifierOrThrow("revanced_settings_arrow_right_one", "drawable");
private static final int DRAWABLE_REVANCED_SETTINGS_ARROW_RIGHT_DOUBLE =
getResourceIdentifierOrThrow("revanced_settings_arrow_right_double", "drawable");
private static final int DRAWABLE_REVANCED_SETTINGS_ARROW_LEFT_ONE =
getResourceIdentifierOrThrow("revanced_settings_arrow_left_one", "drawable");
private static final int DRAWABLE_REVANCED_SETTINGS_ARROW_LEFT_DOUBLE =
getResourceIdentifierOrThrow("revanced_settings_arrow_left_double", "drawable");
/**
* Flags to hide from the UI.
*/
private static final Set<Long> FLAGS_TO_IGNORE = Set.of(
45386834L // 'You' tab settings icon.
);
/**
* Tracks state for range selection in ListView.
*/
private static class ListViewSelectionState {
int lastClickedPosition = -1; // Position of the last clicked item.
boolean isRangeSelecting = false; // True while a range is being selected.
}
/**
* Helper class to pass ListView and Adapter together.
*/
private record ColumnViews(ListView listView, FlagAdapter adapter) {}
{
setOnPreferenceClickListener(pref -> {
showFlagsManagerDialog();
return true;
});
}
public FeatureFlagsManagerPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public FeatureFlagsManagerPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public FeatureFlagsManagerPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
public FeatureFlagsManagerPreference(Context context) {
super(context);
}
/**
* Shows the main dialog for managing feature flags.
*/
private void showFlagsManagerDialog() {
if (!BaseSettings.DEBUG.get()) {
Utils.showToastShort(str("revanced_debug_logs_disabled"));
return;
}
Context context = getContext();
// Load all known and disabled flags.
TreeSet<Long> allKnownFlags = new TreeSet<>(EnableDebuggingPatch.getAllLoggedFlags());
allKnownFlags.removeAll(FLAGS_TO_IGNORE);
TreeSet<Long> disabledFlags = new TreeSet<>(EnableDebuggingPatch.parseFlags(
BaseSettings.DISABLED_FEATURE_FLAGS.get()));
disabledFlags.removeAll(FLAGS_TO_IGNORE);
if (allKnownFlags.isEmpty() && disabledFlags.isEmpty()) {
// String does not need to be localized because it's basically impossible
// to reach the settings menu without encountering at least 1 flag.
Utils.showToastShort("No feature flags logged yet");
return;
}
TreeSet<Long> availableFlags = new TreeSet<>(allKnownFlags);
availableFlags.removeAll(disabledFlags);
TreeSet<Long> blockedFlags = new TreeSet<>(disabledFlags);
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context,
getTitle() != null ? getTitle().toString() : "",
null,
null,
str("revanced_settings_save"),
() -> saveFlags(blockedFlags),
() -> {},
str("revanced_settings_reset"),
this::resetFlags,
true
);
LinearLayout mainLayout = dialogPair.second;
LinearLayout.LayoutParams contentParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1.0f);
// Insert content before the dialog button row.
View contentView = createContentView(context, availableFlags, blockedFlags);
mainLayout.addView(contentView, mainLayout.getChildCount() - 1, contentParams);
Dialog dialog = dialogPair.first;
dialog.show();
Window window = dialog.getWindow();
if (window != null) {
Utils.setDialogWindowParameters(window, Gravity.CENTER, 0, 100, false);
}
}
/**
* Creates the main content view with two columns.
*/
private View createContentView(Context context, TreeSet<Long> availableFlags, TreeSet<Long> blockedFlags) {
LinearLayout contentLayout = new LinearLayout(context);
contentLayout.setOrientation(LinearLayout.VERTICAL);
// Headers.
TextView availableHeader = createHeader(context, "revanced_debug_feature_flags_manager_active_header");
TextView blockedHeader = createHeader(context, "revanced_debug_feature_flags_manager_blocked_header");
LinearLayout headersLayout = new LinearLayout(context);
headersLayout.setOrientation(LinearLayout.HORIZONTAL);
headersLayout.addView(availableHeader, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
headersLayout.addView(blockedHeader, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
// Columns.
View leftColumn = createColumn(context, availableFlags, availableHeader);
View rightColumn = createColumn(context, blockedFlags, blockedHeader);
ColumnViews leftViews = (ColumnViews) leftColumn.getTag();
ColumnViews rightViews = (ColumnViews) rightColumn.getTag();
updateHeaderCount(availableHeader, leftViews.adapter);
updateHeaderCount(blockedHeader, rightViews.adapter);
// Main columns layout.
LinearLayout columnsLayout = new LinearLayout(context);
columnsLayout.setOrientation(LinearLayout.HORIZONTAL);
columnsLayout.setLayoutParams(new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
columnsLayout.addView(leftColumn, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
Space spaceBetweenColumns = new Space(context);
spaceBetweenColumns.setLayoutParams(new LinearLayout.LayoutParams(Dim.dp8, ViewGroup.LayoutParams.MATCH_PARENT));
columnsLayout.addView(spaceBetweenColumns);
columnsLayout.addView(rightColumn, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
// Move buttons below columns.
Pair<LinearLayout, LinearLayout> moveButtons = createMoveButtons(context,
leftViews.listView, rightViews.listView,
availableFlags, blockedFlags, availableHeader, blockedHeader);
// Layout for buttons row.
LinearLayout buttonsRow = new LinearLayout(context);
buttonsRow.setOrientation(LinearLayout.HORIZONTAL);
buttonsRow.setLayoutParams(new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
buttonsRow.addView(moveButtons.first, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
Space spaceBetweenButtons = new Space(context);
spaceBetweenButtons.setLayoutParams(new LinearLayout.LayoutParams(Dim.dp8, ViewGroup.LayoutParams.WRAP_CONTENT));
buttonsRow.addView(spaceBetweenButtons);
buttonsRow.addView(moveButtons.second, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
contentLayout.addView(headersLayout);
contentLayout.addView(columnsLayout);
contentLayout.addView(buttonsRow);
return contentLayout;
}
/**
* Creates a header TextView.
*/
private TextView createHeader(Context context, String tag) {
TextView textview = new TextView(context);
textview.setTag(tag);
textview.setTextSize(16);
textview.setTextColor(Utils.getAppForegroundColor());
textview.setGravity(Gravity.CENTER);
return textview;
}
/**
* Creates a single column (search + buttons + list).
*/
private View createColumn(Context context, TreeSet<Long> flags, TextView countText) {
LinearLayout wrapper = new LinearLayout(context);
wrapper.setOrientation(LinearLayout.VERTICAL);
Pair<ListView, FlagAdapter> pair = createListView(context, flags, countText);
ListView listView = pair.first;
FlagAdapter adapter = pair.second;
EditText search = createSearchBox(context, adapter, listView, countText);
LinearLayout buttons = createActionButtons(context, listView, adapter);
listView.setLayoutParams(new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
ShapeDrawable background = new ShapeDrawable(new RoundRectShape(
Dim.roundedCorners(10), null, null));
background.getPaint().setColor(Utils.getEditTextBackground());
listView.setPadding(0, Dim.dp4, 0, Dim.dp4);
listView.setBackground(background);
listView.setOverScrollMode(View.OVER_SCROLL_NEVER);
wrapper.addView(search);
wrapper.addView(buttons);
wrapper.addView(listView);
// Save references for move buttons.
wrapper.setTag(new ColumnViews(listView, adapter));
return wrapper;
}
/**
* Updates the header text with the current count.
*/
private void updateHeaderCount(TextView header, FlagAdapter adapter) {
header.setText(str((String) header.getTag(), adapter.getCount()));
}
/**
* Creates a search box that filters the list.
*/
@SuppressLint("ClickableViewAccessibility")
private EditText createSearchBox(Context context, FlagAdapter adapter, ListView listView, TextView countText) {
EditText search = new EditText(context);
search.setInputType(InputType.TYPE_CLASS_NUMBER);
search.setTextSize(16);
search.setHint(str("revanced_debug_feature_flags_manager_search_hint"));
search.setHapticFeedbackEnabled(false);
search.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
search.addTextChangedListener(new TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override public void onTextChanged(CharSequence s, int start, int before, int count) {
adapter.setSearchQuery(s.toString());
listView.clearChoices();
updateHeaderCount(countText, adapter);
Drawable clearIcon = context.getResources().getDrawable(android.R.drawable.ic_menu_close_clear_cancel);
clearIcon.setBounds(0, 0, Dim.dp20, Dim.dp20);
search.setCompoundDrawables(null, null, TextUtils.isEmpty(s) ? null : clearIcon, null);
}
@Override public void afterTextChanged(Editable s) {}
});
search.setOnTouchListener((v, event) -> {
if (event.getAction() == MotionEvent.ACTION_UP) {
Drawable[] compoundDrawables = search.getCompoundDrawables();
if (compoundDrawables[2] != null &&
event.getRawX() >= (search.getRight() - compoundDrawables[2].getBounds().width())) {
search.setText("");
return true;
}
}
return false;
});
return search;
}
/**
* Creates action buttons.
*/
private LinearLayout createActionButtons(Context context, ListView listView, FlagAdapter adapter) {
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER);
row.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
ImageButton selectAll = createButton(context, DRAWABLE_REVANCED_SETTINGS_SELECT_ALL,
() -> {
for (int i = 0, count = adapter.getCount(); i < count; i++) {
listView.setItemChecked(i, true);
}
});
ImageButton clearAll = createButton(context, DRAWABLE_REVANCED_SETTINGS_DESELECT_ALL,
() -> {
listView.clearChoices();
adapter.notifyDataSetChanged();
});
ImageButton copy = createButton(context, DRAWABLE_REVANCED_SETTINGS_COPY_ALL,
() -> {
List<String> items = new ArrayList<>();
SparseBooleanArray checked = listView.getCheckedItemPositions();
if (checked.size() > 0) {
for (int i = 0, count = adapter.getCount(); i < count; i++) {
if (checked.get(i)) {
items.add(adapter.getItem(i));
}
}
} else {
for (Long flag : adapter.getFullFlags()) {
items.add(String.valueOf(flag));
}
}
Utils.setClipboard(TextUtils.join("\n", items));
Utils.showToastShort(str("revanced_debug_feature_flags_manager_toast_copied"));
});
row.addView(selectAll);
row.addView(clearAll);
row.addView(copy);
return row;
}
/**
* Creates the move buttons (left and right groups).
*/
private Pair<LinearLayout, LinearLayout> createMoveButtons(Context context,
ListView availableListView, ListView blockedListView,
TreeSet<Long> availableFlags, TreeSet<Long> blockedFlags,
TextView availableCountText, TextView blockedCountText) {
// Left group: >> >
LinearLayout leftButtons = new LinearLayout(context);
leftButtons.setOrientation(LinearLayout.HORIZONTAL);
leftButtons.setGravity(Gravity.CENTER);
ImageButton moveAllRight = createButton(context, DRAWABLE_REVANCED_SETTINGS_ARROW_RIGHT_DOUBLE,
() -> moveFlags(availableListView, blockedListView, availableFlags, blockedFlags,
availableCountText, blockedCountText, true));
ImageButton moveOneRight = createButton(context, DRAWABLE_REVANCED_SETTINGS_ARROW_RIGHT_ONE,
() -> moveFlags(availableListView, blockedListView, availableFlags, blockedFlags,
availableCountText, blockedCountText, false));
leftButtons.addView(moveAllRight);
leftButtons.addView(moveOneRight);
// Right group: < <<
LinearLayout rightButtons = new LinearLayout(context);
rightButtons.setOrientation(LinearLayout.HORIZONTAL);
rightButtons.setGravity(Gravity.CENTER);
ImageButton moveOneLeft = createButton(context, DRAWABLE_REVANCED_SETTINGS_ARROW_LEFT_ONE,
() -> moveFlags(blockedListView, availableListView, blockedFlags, availableFlags,
blockedCountText, availableCountText, false));
ImageButton moveAllLeft = createButton(context, DRAWABLE_REVANCED_SETTINGS_ARROW_LEFT_DOUBLE,
() -> moveFlags(blockedListView, availableListView, blockedFlags, availableFlags,
blockedCountText, availableCountText, true));
rightButtons.addView(moveOneLeft);
rightButtons.addView(moveAllLeft);
return new Pair<>(leftButtons, rightButtons);
}
/**
* Creates a styled ImageButton.
*/
@SuppressLint("ResourceType")
private ImageButton createButton(Context context, int drawableResId, Runnable action) {
ImageButton button = new ImageButton(context);
button.setImageResource(drawableResId);
button.setScaleType(ImageView.ScaleType.CENTER);
int[] attrs = {android.R.attr.selectableItemBackgroundBorderless};
//noinspection Recycle
TypedArray ripple = context.obtainStyledAttributes(attrs);
button.setBackgroundDrawable(ripple.getDrawable(0));
ripple.close();
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(Dim.dp32, Dim.dp32);
params.setMargins(Dim.dp8, Dim.dp8, Dim.dp8, Dim.dp8);
button.setLayoutParams(params);
button.setOnClickListener(v -> action.run());
return button;
}
/**
* Custom adapter with search filtering.
*/
private static class FlagAdapter extends ArrayAdapter<String> {
private final TreeSet<Long> fullFlags;
private String searchQuery = "";
public FlagAdapter(Context context, TreeSet<Long> fullFlags) {
super(context, android.R.layout.simple_list_item_multiple_choice, new ArrayList<>());
this.fullFlags = fullFlags;
updateFiltered();
}
public void setSearchQuery(String query) {
searchQuery = query == null ? "" : query.trim();
updateFiltered();
}
private void updateFiltered() {
clear();
for (Long flag : fullFlags) {
String flagString = String.valueOf(flag);
if (searchQuery.isEmpty() || flagString.contains(searchQuery)) {
add(flagString);
}
}
notifyDataSetChanged();
}
public void refresh() {
updateFiltered();
}
public List<Long> getFullFlags() {
return new ArrayList<>(fullFlags);
}
}
/**
* Creates a ListView with filtering, multi-select, and range selection.
*/
@SuppressLint("ClickableViewAccessibility")
private Pair<ListView, FlagAdapter> createListView(Context context,
TreeSet<Long> flags, TextView countText) {
ListView listView = new ListView(context);
listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
listView.setDividerHeight(0);
FlagAdapter adapter = new FlagAdapter(context, flags);
listView.setAdapter(adapter);
final ListViewSelectionState state = new ListViewSelectionState();
listView.setOnItemClickListener((parent, view, position, id) -> {
if (!state.isRangeSelecting) {
state.lastClickedPosition = position;
} else {
state.isRangeSelecting = false;
}
});
listView.setOnItemLongClickListener((parent, view, position, id) -> {
if (state.lastClickedPosition == -1) {
listView.setItemChecked(position, true);
state.lastClickedPosition = position;
} else {
int start = Math.min(state.lastClickedPosition, position);
int end = Math.max(state.lastClickedPosition, position);
for (int i = start; i <= end; i++) {
listView.setItemChecked(i, true);
}
state.isRangeSelecting = true;
}
return true;
});
listView.setOnTouchListener((view, event) -> {
if (event.getAction() == MotionEvent.ACTION_UP && state.isRangeSelecting) {
state.isRangeSelecting = false;
}
return false;
});
return new Pair<>(listView, adapter);
}
/**
* Moves selected or all flags from one list to another.
*
* @param fromListView Source ListView.
* @param toListView Destination ListView.
* @param fromFlags Source flag set.
* @param toFlags Destination flag set.
* @param fromCountText Header showing count of source items.
* @param toCountText Header showing count of destination items.
* @param moveAll If true, move all items; if false, move only selected.
*/
private void moveFlags(ListView fromListView, ListView toListView,
TreeSet<Long> fromFlags, TreeSet<Long> toFlags,
TextView fromCountText, TextView toCountText,
boolean moveAll) {
if (fromListView == null || toListView == null) return;
List<Long> flagsToMove = new ArrayList<>();
FlagAdapter fromAdapter = (FlagAdapter) fromListView.getAdapter();
if (moveAll) {
flagsToMove.addAll(fromFlags);
} else {
SparseBooleanArray checked = fromListView.getCheckedItemPositions();
for (int i = 0, count = fromAdapter.getCount(); i < count; i++) {
if (checked.get(i)) {
String item = fromAdapter.getItem(i);
if (item != null) {
flagsToMove.add(Long.parseLong(item));
}
}
}
}
if (flagsToMove.isEmpty()) return;
for (Long flag : flagsToMove) {
fromFlags.remove(flag);
toFlags.add(flag);
}
// Clear selections before refreshing.
fromListView.clearChoices();
toListView.clearChoices();
// Refresh both adapters.
fromAdapter.refresh();
((FlagAdapter) toListView.getAdapter()).refresh();
// Update headers.
updateHeaderCount(fromCountText, fromAdapter);
updateHeaderCount(toCountText, (FlagAdapter) toListView.getAdapter());
}
/**
* Saves blocked flags to settings.
*/
private void saveFlags(TreeSet<Long> blockedFlags) {
StringBuilder flagsString = new StringBuilder();
for (Long flag : blockedFlags) {
if (flagsString.length() > 0) {
flagsString.append("\n");
}
flagsString.append(flag);
}
BaseSettings.DISABLED_FEATURE_FLAGS.save(flagsString.toString());
Utils.showToastShort(str("revanced_debug_feature_flags_manager_toast_saved"));
Logger.printDebug(() -> "Feature flags saved. Blocked: " + blockedFlags.size());
AbstractPreferenceFragment.showRestartDialog(getContext());
}
/**
* Resets all blocked flags.
*/
private void resetFlags() {
BaseSettings.DISABLED_FEATURE_FLAGS.save("");
Utils.showToastShort(str("revanced_debug_feature_flags_manager_toast_reset"));
AbstractPreferenceFragment.showRestartDialog(getContext());
}
}

View File

@@ -0,0 +1,63 @@
package app.revanced.extension.shared.settings.preference;
import static app.revanced.extension.shared.StringRef.str;
import android.content.Context;
import android.preference.SwitchPreference;
import android.util.AttributeSet;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.spoof.ClientType;
import app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch;
@SuppressWarnings({"deprecation", "unused"})
public class ForceOriginalAudioSwitchPreference extends SwitchPreference {
// Spoof stream patch is not included, or is not currently spoofing to Android Studio.
private static final boolean available = !SpoofVideoStreamsPatch.isPatchIncluded()
|| !(BaseSettings.SPOOF_VIDEO_STREAMS.get()
&& SpoofVideoStreamsPatch.getPreferredClient() == ClientType.ANDROID_CREATOR);
{
if (!available) {
// Show why force audio is not available.
String summary = str("revanced_force_original_audio_not_available");
super.setSummary(summary);
super.setSummaryOn(summary);
super.setSummaryOff(summary);
super.setEnabled(false);
}
}
public ForceOriginalAudioSwitchPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public ForceOriginalAudioSwitchPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public ForceOriginalAudioSwitchPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ForceOriginalAudioSwitchPreference(Context context) {
super(context);
}
@Override
public void setEnabled(boolean enabled) {
if (!available) {
return;
}
super.setEnabled(enabled);
}
@Override
public void setSummary(CharSequence summary) {
if (!available) {
return;
}
super.setSummary(summary);
}
}

View File

@@ -1,7 +1,6 @@
package app.revanced.extension.shared.settings.preference; package app.revanced.extension.shared.settings.preference;
import static app.revanced.extension.shared.StringRef.str; import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.shared.Utils.dipToPixels;
import android.app.Dialog; import android.app.Dialog;
import android.content.Context; import android.content.Context;
@@ -10,21 +9,16 @@ import android.os.Bundle;
import android.preference.EditTextPreference; import android.preference.EditTextPreference;
import android.preference.Preference; import android.preference.Preference;
import android.text.InputType; import android.text.InputType;
import android.text.TextUtils;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.util.Pair; import android.util.Pair;
import android.util.TypedValue; import android.view.inputmethod.InputMethodManager;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText; import android.widget.EditText;
import android.widget.LinearLayout; 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.Logger;
import app.revanced.extension.shared.Utils; import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.Setting; import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.shared.ui.CustomDialog;
@SuppressWarnings({"unused", "deprecation"}) @SuppressWarnings({"unused", "deprecation"})
public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener { public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener {
@@ -40,7 +34,7 @@ public class ImportExportPreference extends EditTextPreference implements Prefer
editText.setAutofillHints((String) null); editText.setAutofillHints((String) null);
} }
editText.setInputType(editText.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); editText.setInputType(editText.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
editText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 7); // Use a smaller font to reduce text wrap. editText.setTextSize(14);
setOnPreferenceClickListener(this); setOnPreferenceClickListener(this);
} }
@@ -82,7 +76,7 @@ public class ImportExportPreference extends EditTextPreference implements Prefer
EditText editText = getEditText(); EditText editText = getEditText();
// Create a custom dialog with the EditText. // Create a custom dialog with the EditText.
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog( Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context, context,
str("revanced_pref_import_export_title"), // Title. str("revanced_pref_import_export_title"), // Title.
null, // No message (EditText replaces it). null, // No message (EditText replaces it).
@@ -98,6 +92,20 @@ public class ImportExportPreference extends EditTextPreference implements Prefer
true // Dismiss dialog when onNeutralClick. 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. // Show the dialog.
dialogPair.first.show(); dialogPair.first.show();
} catch (Exception ex) { } catch (Exception ex) {

View File

@@ -1,10 +1,10 @@
package app.revanced.extension.shared.settings.preference; package app.revanced.extension.shared.settings.preference;
import static app.revanced.extension.shared.StringRef.str; import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.shared.Utils.dipToPixels;
import static app.revanced.extension.shared.requests.Route.Method.GET; import static app.revanced.extension.shared.requests.Route.Method.GET;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.Dialog; import android.app.Dialog;
import android.app.ProgressDialog; import android.app.ProgressDialog;
import android.content.Context; import android.content.Context;
@@ -17,6 +17,7 @@ import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.preference.Preference; import android.preference.Preference;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.view.Gravity;
import android.view.View; import android.view.View;
import android.view.Window; import android.view.Window;
import android.webkit.WebView; import android.webkit.WebView;
@@ -39,6 +40,7 @@ import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils; import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.requests.Requester; import app.revanced.extension.shared.requests.Requester;
import app.revanced.extension.shared.requests.Route; import app.revanced.extension.shared.requests.Route;
import app.revanced.extension.shared.ui.Dim;
/** /**
* Opens a dialog showing official links. * Opens a dialog showing official links.
@@ -124,6 +126,8 @@ public class ReVancedAboutPreference extends Preference {
{ {
setOnPreferenceClickListener(pref -> { setOnPreferenceClickListener(pref -> {
Context context = pref.getContext();
// Show a progress spinner if the social links are not fetched yet. // Show a progress spinner if the social links are not fetched yet.
if (!AboutLinksRoutes.hasFetchedLinks() && Utils.isNetworkConnected()) { if (!AboutLinksRoutes.hasFetchedLinks() && Utils.isNetworkConnected()) {
// Show a progress spinner, but only if the api fetch takes more than a half a second. // Show a progress spinner, but only if the api fetch takes more than a half a second.
@@ -136,17 +140,18 @@ public class ReVancedAboutPreference extends Preference {
handler.postDelayed(showDialogRunnable, delayToShowProgressSpinner); handler.postDelayed(showDialogRunnable, delayToShowProgressSpinner);
Utils.runOnBackgroundThread(() -> Utils.runOnBackgroundThread(() ->
fetchLinksAndShowDialog(handler, showDialogRunnable, progress)); fetchLinksAndShowDialog(context, handler, showDialogRunnable, progress));
} else { } else {
// No network call required and can run now. // No network call required and can run now.
fetchLinksAndShowDialog(null, null, null); fetchLinksAndShowDialog(context, null, null, null);
} }
return false; return false;
}); });
} }
private void fetchLinksAndShowDialog(@Nullable Handler handler, private void fetchLinksAndShowDialog(Context context,
@Nullable Handler handler,
Runnable showDialogRunnable, Runnable showDialogRunnable,
@Nullable ProgressDialog progress) { @Nullable ProgressDialog progress) {
WebLink[] links = AboutLinksRoutes.fetchAboutLinks(); WebLink[] links = AboutLinksRoutes.fetchAboutLinks();
@@ -163,7 +168,17 @@ public class ReVancedAboutPreference extends Preference {
if (handler != null) { if (handler != null) {
handler.removeCallbacks(showDialogRunnable); handler.removeCallbacks(showDialogRunnable);
} }
if (progress != null) {
// Don't continue if the activity is done. To test this tap the
// about dialog and immediately press back before the dialog can show.
if (context instanceof Activity activity) {
if (activity.isFinishing() || activity.isDestroyed()) {
Logger.printDebug(() -> "Not showing about dialog, activity is closed");
return;
}
}
if (progress != null && progress.isShowing()) {
progress.dismiss(); progress.dismiss();
} }
new WebViewDialog(getContext(), htmlDialog).show(); new WebViewDialog(getContext(), htmlDialog).show();
@@ -207,11 +222,10 @@ class WebViewDialog extends Dialog {
LinearLayout mainLayout = new LinearLayout(getContext()); LinearLayout mainLayout = new LinearLayout(getContext());
mainLayout.setOrientation(LinearLayout.VERTICAL); mainLayout.setOrientation(LinearLayout.VERTICAL);
final int padding = dipToPixels(10); mainLayout.setPadding(Dim.dp10, Dim.dp10, Dim.dp10, Dim.dp10);
mainLayout.setPadding(padding, padding, padding, padding);
// Set rounded rectangle background. // Set rounded rectangle background.
ShapeDrawable mainBackground = new ShapeDrawable(new RoundRectShape( ShapeDrawable mainBackground = new ShapeDrawable(new RoundRectShape(
Utils.createCornerRadii(28), null, null)); Dim.roundedCorners(28), null, null));
mainBackground.getPaint().setColor(Utils.getDialogBackgroundColor()); mainBackground.getPaint().setColor(Utils.getDialogBackgroundColor());
mainLayout.setBackground(mainBackground); mainLayout.setBackground(mainBackground);
@@ -228,10 +242,10 @@ class WebViewDialog extends Dialog {
setContentView(mainLayout); setContentView(mainLayout);
// Set dialog window attributes // Set dialog window attributes.
Window window = getWindow(); Window window = getWindow();
if (window != null) { 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 java.util.Objects;
import app.revanced.extension.shared.Logger; 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.Setting;
import app.revanced.extension.shared.ui.CustomDialog;
@SuppressWarnings({"unused", "deprecation"}) @SuppressWarnings({"unused", "deprecation"})
public class ResettableEditTextPreference extends EditTextPreference { public class ResettableEditTextPreference extends EditTextPreference {
@@ -66,7 +66,7 @@ public class ResettableEditTextPreference extends EditTextPreference {
// Create custom dialog. // Create custom dialog.
String neutralButtonText = (setting != null) ? str("revanced_settings_reset") : null; String neutralButtonText = (setting != null) ? str("revanced_settings_reset") : null;
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog( Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context, context,
getTitle() != null ? getTitle().toString() : "", // Title. getTitle() != null ? getTitle().toString() : "", // Title.
null, // Message is replaced by EditText. null, // Message is replaced by EditText.

View File

@@ -0,0 +1,168 @@
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.PreferenceGroup;
import android.preference.PreferenceScreen;
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;
import app.revanced.extension.shared.ui.Dim;
@SuppressWarnings({"deprecation", "NewApi"})
public class ToolbarPreferenceFragment extends AbstractPreferenceFragment {
/**
* Removes the list of preferences from this fragment, if they exist.
* @param keys Preference keys.
*/
protected void removePreferences(String ... keys) {
for (String key : keys) {
Preference pref = findPreference(key);
if (pref != null) {
PreferenceGroup parent = pref.getParent();
if (parent != null) {
Logger.printDebug(() -> "Removing preference: " + key);
parent.removePreference(pref);
}
}
}
}
/**
* 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());
toolbar.setTitleMargin(Dim.dp16, 0, Dim.dp16, 0);
TextView toolbarTextView = Utils.getChildView(toolbar,
true, TextView.class::isInstance);
if (toolbarTextView != null) {
toolbarTextView.setTextColor(Utils.getAppForegroundColor());
toolbarTextView.setTextSize(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.Context;
import android.content.Intent; import android.content.Intent;

View File

@@ -0,0 +1,372 @@
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) || queryPattern == null) 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()) {
int start = matcher.start();
int end = matcher.end();
if (start == end) continue; // Skip zero matches.
spannable.setSpan(highlightSpan, start, 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();
}
/**
* Appends normalized searchable text to the builder.
* Uses full Unicode normalization for accurate search across all languages.
*/
private void appendText(StringBuilder builder, CharSequence text) {
if (!TextUtils.isEmpty(text)) {
if (builder.length() > 0) builder.append(" ");
builder.append(Utils.normalizeTextToLowercase(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.normalizeTextToLowercase(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, count = adapter.getCount(); i < count; 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();
}
final int startColor = Utils.getAppBackgroundColor();
final 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, count = group.getPreferenceCount(); i < count; 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,683 @@
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.Color;
import android.graphics.drawable.GradientDrawable;
import android.os.Build;
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 androidx.annotation.RequiresApi;
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;
import app.revanced.extension.shared.ui.Dim;
/**
* 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"));
// Set text size.
searchEditText.setTextSize(16);
// Set cursor color.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setCursorColor(searchEditText);
}
// 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);
}
}
/**
* Sets the cursor color (for Android 10+ devices).
*/
@RequiresApi(api = Build.VERSION_CODES.Q)
private void setCursorColor(EditText editText) {
// Get the cursor color based on the current theme.
final int cursorColor = Utils.isDarkModeEnabled() ? Color.WHITE : Color.BLACK;
// Create cursor drawable.
GradientDrawable cursorDrawable = new GradientDrawable();
cursorDrawable.setShape(GradientDrawable.RECTANGLE);
cursorDrawable.setSize(Dim.dp2, -1); // Width: 2dp, Height: match text height.
cursorDrawable.setColor(cursorColor);
// Set cursor drawable.
editText.setTextCursorDrawable(cursorDrawable);
}
/**
* 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(Dim.dp8);
// 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.normalizeTextToLowercase(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(Dim.dp28);
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,15 +2,20 @@ package app.revanced.extension.shared.spoof;
import android.os.Build; import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import app.revanced.extension.shared.Logger; import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.settings.BaseSettings;
@SuppressWarnings("ConstantLocale")
public enum ClientType { 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 // https://dumps.tadiphone.dev/dumps/oculus/eureka
ANDROID_VR_1_61_48( ANDROID_VR_1_61_48(
28, 28,
@@ -29,27 +34,57 @@ public enum ClientType {
false, false,
"Android VR 1.61" "Android VR 1.61"
), ),
// Chromecast with Google TV 4K. /**
// https://dumps.tadiphone.dev/dumps/google/kirkwood * Uses non adaptive bitrate, which fixes audio stuttering with YT Music.
ANDROID_UNPLUGGED( * Does not use AV1.
29, */
"ANDROID_UNPLUGGED", ANDROID_VR_1_43_32(
"com.google.android.apps.youtube.unplugged", ANDROID_VR_1_61_48.id,
"Google", ANDROID_VR_1_61_48.clientName,
"Google TV Streamer", Objects.requireNonNull(ANDROID_VR_1_61_48.packageName),
"Android", ANDROID_VR_1_61_48.deviceMake,
"14", ANDROID_VR_1_61_48.deviceModel,
"34", ANDROID_VR_1_61_48.osName,
"UTT3.240625.001.K5", ANDROID_VR_1_61_48.osVersion,
"132.0.6808.3", Objects.requireNonNull(ANDROID_VR_1_61_48.androidSdkVersion),
"8.49.0", Objects.requireNonNull(ANDROID_VR_1_61_48.buildId),
true, "107.0.5284.2",
true, "1.43.32",
"Android TV" ANDROID_VR_1_61_48.useAuth,
ANDROID_VR_1_61_48.supportsMultiAudioTracks,
"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 * Video not playable: Paid / Movie / Private / Age-restricted.
// https://dumps.tadiphone.dev/dumps/google/barbet * Note: The 'Authorization' key must be excluded from the header.
*
* According to TeamNewPipe in 2022, if the 'androidSdkVersion' field is missing,
* the GVS did not return a valid response:
* [NewPipe#8713 (comment)](https://github.com/TeamNewPipe/NewPipe/issues/8713#issuecomment-1207443550).
*
* According to the latest commit in yt-dlp, the GVS returns a valid response
* even if the 'androidSdkVersion' field is missing:
* [yt-dlp#14693](https://github.com/yt-dlp/yt-dlp/pull/14693).
*
* For some reason, PoToken is not required.
*/
ANDROID_NO_SDK(
3,
"ANDROID",
"",
"",
"",
Build.VERSION.RELEASE,
"20.05.46",
"com.google.android.youtube/20.05.46 (Linux; U; Android " + Build.VERSION.RELEASE + ") gzip",
false,
true,
"Android No SDK"
),
/**
* 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( ANDROID_CREATOR(
14, 14,
"ANDROID_CREATOR", "ANDROID_CREATOR",
@@ -63,65 +98,50 @@ public enum ClientType {
"132.0.6779.0", "132.0.6779.0",
"23.47.101", "23.47.101",
true, true,
true, false,
"Android Creator" "Android Studio"
),
IOS_UNPLUGGED(
33,
"IOS_UNPLUGGED",
"com.google.ios.youtubeunplugged",
"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"
), ),
/** /**
* Uses non adaptive bitrate, which fixes audio stuttering with YT Music. * Internal YT client for an unreleased YT client. May stop working at any time.
* Uses VP9 and not AV1.
*/ */
ANDROID_VR_1_43_32( VISIONOS(101,
ANDROID_VR_1_61_48.id, "VISIONOS",
ANDROID_VR_1_61_48.clientName, "Apple",
ANDROID_VR_1_61_48.packageName, "RealityDevice14,1",
ANDROID_VR_1_61_48.deviceMake, "visionOS",
ANDROID_VR_1_61_48.deviceModel, "1.3.21O771",
ANDROID_VR_1_61_48.osName, "0.1",
ANDROID_VR_1_61_48.osVersion, "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",
ANDROID_VR_1_61_48.androidSdkVersion, false,
ANDROID_VR_1_61_48.buildId, false,
"107.0.5284.2", "visionOS"
"1.43.32", ),
ANDROID_VR_1_61_48.requiresAuth, /**
ANDROID_VR_1_61_48.useAuth, * The device machine id for the iPad 6th Gen (iPad7,6).
"Android VR 1.43" * 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,
true,
"iPadOS"
); );
private static boolean forceAVC() {
return BaseSettings.SPOOF_VIDEO_STREAMS_IOS_FORCE_AVC.get();
}
/** /**
* YouTube * YouTube
* <a href="https://github.com/zerodytrash/YouTube-Internal-Clients?tab=readme-ov-file#clients">client type</a> * <a href="https://github.com/zerodytrash/YouTube-Internal-Clients?tab=readme-ov-file#clients">client type</a>
@@ -133,6 +153,7 @@ public enum ClientType {
/** /**
* App package name. * App package name.
*/ */
@Nullable
private final String packageName; private final String packageName;
/** /**
@@ -186,36 +207,37 @@ public enum ClientType {
*/ */
public final String clientVersion; 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. * If the client should use authentication if available.
*/ */
public final boolean useAuth; public final boolean useAuth;
/**
* If the client supports multiple audio tracks.
*/
public final boolean supportsMultiAudioTracks;
/** /**
* Friendly name displayed in stats for nerds. * Friendly name displayed in stats for nerds.
*/ */
public final String friendlyName; public final String friendlyName;
@SuppressWarnings("ConstantLocale") /**
* Android constructor.
*/
ClientType(int id, ClientType(int id,
String clientName, String clientName,
String packageName, @NonNull String packageName,
String deviceMake, String deviceMake,
String deviceModel, String deviceModel,
String osName, String osName,
String osVersion, String osVersion,
@Nullable String androidSdkVersion, @NonNull String androidSdkVersion,
@Nullable String buildId, @NonNull String buildId,
@Nullable String cronetVersion, @NonNull String cronetVersion,
String clientVersion, String clientVersion,
boolean requiresAuth,
boolean useAuth, boolean useAuth,
boolean supportsMultiAudioTracks,
String friendlyName) { String friendlyName) {
this.id = id; this.id = id;
this.clientName = clientName; this.clientName = clientName;
@@ -228,36 +250,49 @@ public enum ClientType {
this.buildId = buildId; this.buildId = buildId;
this.cronetVersion = cronetVersion; this.cronetVersion = cronetVersion;
this.clientVersion = clientVersion; this.clientVersion = clientVersion;
this.requiresAuth = requiresAuth;
this.useAuth = useAuth; this.useAuth = useAuth;
this.supportsMultiAudioTracks = supportsMultiAudioTracks;
this.friendlyName = friendlyName; this.friendlyName = friendlyName;
Locale defaultLocale = Locale.getDefault(); Locale defaultLocale = Locale.getDefault();
if (androidSdkVersion == null) { this.userAgent = String.format("%s/%s (Linux; U; Android %s; %s; %s; Build/%s; Cronet/%s)",
// Convert version from '18.2.22C152' into '18_2_22' packageName,
String userAgentOsVersion = osVersion clientVersion,
.replaceAll("(\\d+\\.\\d+\\.\\d+).*", "$1") osVersion,
.replace(".", "_"); defaultLocale,
// https://github.com/mitmproxy/mitmproxy/issues/4836 deviceModel,
this.userAgent = String.format("%s/%s (%s; U; CPU iOS %s like Mac OS X; %s)", Objects.requireNonNull(buildId),
packageName, Objects.requireNonNull(cronetVersion)
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)
);
}
Logger.printDebug(() -> "userAgent: " + this.userAgent); 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,
boolean supportsMultiAudioTracks,
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.supportsMultiAudioTracks = supportsMultiAudioTracks;
this.friendlyName = friendlyName;
this.packageName = null;
this.androidSdkVersion = null;
this.buildId = null;
this.cronetVersion = null;
}
} }

View File

@@ -6,20 +6,18 @@ import android.text.TextUtils;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import app.revanced.extension.shared.Logger; import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils; 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.BaseSettings;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.shared.spoof.requests.StreamingDataRequest; import app.revanced.extension.shared.spoof.requests.StreamingDataRequest;
@SuppressWarnings("unused") @SuppressWarnings("unused")
public class SpoofVideoStreamsPatch { public class SpoofVideoStreamsPatch {
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;
/** /**
* Domain used for internet connectivity verification. * Domain used for internet connectivity verification.
@@ -36,17 +34,45 @@ public class SpoofVideoStreamsPatch {
private static final String INTERNET_CONNECTION_CHECK_URI_STRING = "https://www.google.com/gen_204"; 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 Uri INTERNET_CONNECTION_CHECK_URI = Uri.parse(INTERNET_CONNECTION_CHECK_URI_STRING);
private static final boolean SPOOF_STREAMING_DATA = BaseSettings.SPOOF_VIDEO_STREAMS.get();
@Nullable
private static volatile AppLanguage languageOverride;
private static volatile ClientType preferredClient = ClientType.ANDROID_VR_1_43_32;
/** /**
* @return If this patch was included during patching. * @return If this patch was included during patching.
*/ */
private static boolean isPatchIncluded() { public static boolean isPatchIncluded() {
return false; // Modified during patching. return false; // Modified during patching.
} }
public static boolean notSpoofingToAndroid() { @Nullable
return !isPatchIncluded() public static AppLanguage getLanguageOverride() {
|| !BaseSettings.SPOOF_VIDEO_STREAMS.get() return languageOverride;
|| BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.IOS_UNPLUGGED; }
/**
* @param language Language override for non-authenticated requests.
*/
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 ClientType getPreferredClient() {
return preferredClient;
}
public static boolean spoofingToClientWithNoMultiAudioStreams() {
return isPatchIncluded()
&& SPOOF_STREAMING_DATA
&& !preferredClient.supportsMultiAudioTracks;
} }
/** /**
@@ -74,6 +100,35 @@ public class SpoofVideoStreamsPatch {
return playerRequestUri; 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. * Injection point.
* <p> * <p>
@@ -107,7 +162,7 @@ public class SpoofVideoStreamsPatch {
/** /**
* Injection point. * 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) { public static boolean fixHLSCurrentTime(boolean original) {
if (!SPOOF_STREAMING_DATA) { if (!SPOOF_STREAMING_DATA) {
@@ -116,6 +171,14 @@ public class SpoofVideoStreamsPatch {
return false; return false;
} }
/*
* Injection point.
* Fix audio stuttering in YouTube Music.
*/
public static boolean disableSABR() {
return SPOOF_STREAMING_DATA;
}
/** /**
* Injection point. * Injection point.
* Turns off a feature flag that interferes with spoofing. * Turns off a feature flag that interferes with spoofing.
@@ -257,21 +320,4 @@ public class SpoofVideoStreamsPatch {
return videoFormat; return videoFormat;
} }
public static final class AudioStreamLanguageOverrideAvailability implements Setting.Availability {
@Override
public boolean isAvailable() {
ClientType clientType = BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get();
return BaseSettings.SPOOF_VIDEO_STREAMS.get()
&& (clientType == ClientType.ANDROID_VR_1_61_48 || clientType == ClientType.ANDROID_VR_1_43_32);
}
}
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;
}
}
} }

View File

@@ -10,8 +10,9 @@ import java.util.Locale;
import app.revanced.extension.shared.Logger; import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.requests.Requester; import app.revanced.extension.shared.requests.Requester;
import app.revanced.extension.shared.requests.Route; import app.revanced.extension.shared.requests.Route;
import app.revanced.extension.shared.settings.BaseSettings; import app.revanced.extension.shared.settings.AppLanguage;
import app.revanced.extension.shared.spoof.ClientType; import app.revanced.extension.shared.spoof.ClientType;
import app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch;
final class PlayerRoutes { final class PlayerRoutes {
static final Route.CompiledRoute GET_STREAMING_DATA = new Route( static final Route.CompiledRoute GET_STREAMING_DATA = new Route(
@@ -37,15 +38,13 @@ final class PlayerRoutes {
try { try {
JSONObject context = new JSONObject(); JSONObject context = new JSONObject();
// Can override default language only if no login is used. AppLanguage language = SpoofVideoStreamsPatch.getLanguageOverride();
// Could use preferred audio for all clients that do not login, if (language == null) {
// but if this is a fall over client it will set the language even though // Force original audio has not overrode the language.
// the audio language is not selectable in the UI. language = AppLanguage.DEFAULT;
ClientType userSelectedClient = BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get(); }
Locale streamLocale = (userSelectedClient == ClientType.ANDROID_VR_1_61_48 //noinspection ExtractMethodRecommender
|| userSelectedClient == ClientType.ANDROID_VR_1_43_32) Locale streamLocale = language.getLocale();
? BaseSettings.SPOOF_VIDEO_STREAMS_LANGUAGE.get().getLocale()
: Locale.getDefault();
JSONObject client = new JSONObject(); JSONObject client = new JSONObject();
client.put("deviceMake", clientType.deviceMake); client.put("deviceMake", clientType.deviceMake);

View File

@@ -1,5 +1,6 @@
package app.revanced.extension.shared.spoof.requests; 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 static app.revanced.extension.shared.spoof.requests.PlayerRoutes.GET_STREAMING_DATA;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@@ -13,12 +14,18 @@ import java.net.HttpURLConnection;
import java.net.SocketTimeoutException; import java.net.SocketTimeoutException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets; 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.ExecutionException;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import app.revanced.extension.shared.ByteTrieSearch;
import app.revanced.extension.shared.Logger; import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils; import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.BaseSettings; import app.revanced.extension.shared.settings.BaseSettings;
@@ -37,10 +44,15 @@ public class StreamingDataRequest {
private static volatile ClientType[] clientOrderToUse = ClientType.values(); private static volatile ClientType[] clientOrderToUse = ClientType.values();
public static void setClientOrderToUse(ClientType[] availableClients, ClientType preferredClient) { public static void setClientOrderToUse(List<ClientType> availableClients, ClientType preferredClient) {
Objects.requireNonNull(availableClients); Objects.requireNonNull(preferredClient);
clientOrderToUse = new ClientType[availableClients.length]; int availableClientSize = availableClients.size();
if (!availableClients.contains(preferredClient)) {
availableClientSize++;
}
clientOrderToUse = new ClientType[availableClientSize];
clientOrderToUse[0] = preferredClient; clientOrderToUse[0] = preferredClient;
int i = 1; int i = 1;
@@ -88,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; private static volatile ClientType lastSpoofedClientType;
public static String getLastSpoofedClientName() { public static String getLastSpoofedClientName() {
@@ -155,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 Logger.printDebug(() -> "Skipping client since user is not logged in: " + clientType
+ " videoId: " + videoId); + " videoId: " + videoId);
return null; return null;
@@ -216,9 +238,13 @@ public class StreamingDataRequest {
while ((bytesRead = inputStream.read(buffer)) >= 0) { while ((bytesRead = inputStream.read(buffer)) >= 0) {
baos.write(buffer, 0, bytesRead); 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) { } catch (IOException ex) {

View File

@@ -0,0 +1,48 @@
package app.revanced.extension.shared.theme;
import androidx.annotation.Nullable;
import app.revanced.extension.shared.Utils;
@SuppressWarnings("unused")
public abstract class BaseThemePatch {
// Background colors.
protected static final int BLACK_COLOR = Utils.getResourceColor("yt_black1");
protected static final int WHITE_COLOR = Utils.getResourceColor("yt_white1");
/**
* Check if a value matches any of the provided values.
*
* @param value The value to check.
* @param of The array of values to compare against.
* @return True if the value matches any of the provided values.
*/
protected static boolean anyEquals(int value, int... of) {
for (int v : of) {
if (value == v) {
return true;
}
}
return false;
}
/**
* Helper method to process color values for Litho components.
*
* @param originalValue The original color value.
* @param darkValues Array of dark mode color values to match.
* @param lightValues Array of light mode color values to match.
* @return The new or original color value.
*/
protected static int processColorValue(int originalValue, int[] darkValues, @Nullable int[] lightValues) {
if (Utils.isDarkModeEnabled()) {
if (anyEquals(originalValue, darkValues)) {
return BLACK_COLOR;
}
} else if (lightValues != null && anyEquals(originalValue, lightValues)) {
return WHITE_COLOR;
}
return originalValue;
}
}

View File

@@ -0,0 +1,60 @@
package app.revanced.extension.shared.ui;
import static app.revanced.extension.shared.Utils.adjustColorBrightness;
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 = Dim.dp(1.5f);
/**
* 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(Dim.dp2);
}
}
}

View File

@@ -0,0 +1,461 @@
package app.revanced.extension.shared.ui;
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;
/**
* 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, CharSequence title, CharSequence message,
@Nullable EditText editText, CharSequence okButtonText,
Runnable onOkClick, Runnable onCancelClick,
@Nullable CharSequence 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, CharSequence title, CharSequence message, @Nullable EditText editText,
CharSequence okButtonText, Runnable onOkClick, Runnable onCancelClick,
@Nullable CharSequence 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.
// 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(Dim.dp24, Dim.dp16, Dim.dp24, Dim.dp24);
// Set rounded rectangle background.
ShapeDrawable background = new ShapeDrawable(new RoundRectShape(
Dim.roundedCorners(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(CharSequence 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, Dim.dp16);
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(
Dim.roundedCorners(10), null, null));
background.getPaint().setColor(Utils.getEditTextBackground());
scrollView.setPadding(Dim.dp8, Dim.dp8, Dim.dp8, Dim.dp8);
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(CharSequence okButtonText, Runnable onOkClick, Runnable onCancelClick,
@Nullable CharSequence 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, Dim.dp16, 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(CharSequence 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(Dim.dp16, 0, Dim.dp16, 0);
// Background color for OK button (inversion).
// Background color for Cancel or Neutral buttons.
ShapeDrawable background = new ShapeDrawable(new RoundRectShape(
Dim.roundedCorners(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 totalWidth = 0;
for (Integer width : buttonWidths) {
totalWidth += width;
}
if (buttonWidths.size() > 1) {
// Add margins for gaps.
totalWidth += (buttonWidths.size() - 1) * Dim.dp8;
}
// Single button: stretch to full width.
if (buttons.size() == 1) {
layoutSingleButton(buttonContainer, buttons.get(0));
} else if (totalWidth <= Dim.pctWidth(80)) {
// 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,
Dim.dp36));
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, Dim.dp36, 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 : Dim.dp4, 0, i == 0 ? Dim.dp4 : 0, 0);
} else if (buttons.size() == 3) {
// Neutral.
// Cancel.
// OK.
params.setMargins(i == 0 ? 0 : Dim.dp4, 0, i == 2 ? 0 : Dim.dp4, 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,
Dim.dp36));
ViewGroup parent = (ViewGroup) button.getParent();
if (parent != null) parent.removeView(button);
button.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
Dim.dp36));
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,
Dim.dp8);
spacer.setLayoutParams(spacerParams);
buttonContainer.addView(spacer);
}
}
}
}

View File

@@ -0,0 +1,89 @@
package app.revanced.extension.shared.ui;
import android.content.res.Resources;
import android.util.DisplayMetrics;
import android.util.TypedValue;
/**
* Utility class for converting design units (dp) and screen percentages to pixels.
*/
public final class Dim {
private Dim() {} // Prevent instantiation.
private static final DisplayMetrics METRICS = Resources.getSystem().getDisplayMetrics();
public static final int SCREEN_WIDTH = METRICS.widthPixels;
public static final int SCREEN_HEIGHT = METRICS.heightPixels;
// DP constants (density-independent pixels).
public static final int dp1 = dp(1);
public static final int dp2 = dp(2);
public static final int dp4 = dp(4);
public static final int dp6 = dp(6);
public static final int dp7 = dp(7);
public static final int dp8 = dp(8);
public static final int dp10 = dp(10);
public static final int dp12 = dp(12);
public static final int dp16 = dp(16);
public static final int dp20 = dp(20);
public static final int dp24 = dp(24);
public static final int dp28 = dp(28);
public static final int dp32 = dp(32);
public static final int dp36 = dp(36);
public static final int dp40 = dp(40);
public static final int dp48 = dp(48);
/**
* Converts dp (density-independent pixels) to actual device pixels.
* Uses Android's official TypedValue.applyDimension() for accurate rounding.
*
* @param dp The dp value to convert (supports float, e.g. 1.2f).
* @return The equivalent pixel value as int.
*/
public static int dp(float dp) {
return (int) TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, dp, METRICS);
}
/**
* Converts a percentage of the screen height to pixels.
*
* @param percent The percentage (0100).
* @return The pixel value corresponding to the percentage of screen height.
*/
public static int pctHeight(int percent) {
return (SCREEN_HEIGHT * percent) / 100;
}
/**
* Converts a percentage of the screen width to pixels.
*
* @param percent The percentage (0100).
* @return The pixel value corresponding to the percentage of screen width.
*/
public static int pctWidth(int percent) {
return (SCREEN_WIDTH * percent) / 100;
}
/**
* Converts a percentage of the screen's portrait width (min side) to pixels.
*
* @param percent The percentage (0100).
* @return The pixel value.
*/
public static int pctPortraitWidth(int percent) {
final int portraitWidth = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT);
return (int) (portraitWidth * (percent / 100.0f));
}
/**
* Creates an array of corner radii for a rounded rectangle.
* All corners use the same radius.
*
* @param dp radius in density-independent pixels
* @return array of 8 floats: [top-left-x, top-left-y, top-right-x, top-right-y, ...]
*/
public static float[] roundedCorners(float dp) {
final float r = dp(dp);
return new float[]{r, r, r, r, r, r, r, r};
}
}

View File

@@ -0,0 +1,455 @@
package app.revanced.extension.shared.ui;
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);
LinearLayout.LayoutParams spacerParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, Dim.dp40);
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) {
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(Dim.dp8, 0, Dim.dp8, Dim.dp8);
mainLayout.setLayoutParams(layoutParams);
ShapeDrawable background = new ShapeDrawable(new RoundRectShape(
Dim.roundedCorners(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, Dim.dp8, 0, 0);
handleContainer.setLayoutParams(containerParams);
handleContainer.setGravity(Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM);
View handleBar = new View(context);
ShapeDrawable handleBackground = new ShapeDrawable(new RoundRectShape(
Dim.roundedCorners(4), null, null));
handleBackground.getPaint().setColor(Utils.adjustColorBrightness(color, 0.9f, 1.25f));
LinearLayout.LayoutParams handleParams = new LinearLayout.LayoutParams(Dim.dp40, Dim.dp4);
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

@@ -7,6 +7,7 @@ import app.revanced.extension.spotify.shared.ComponentFilters.StringComponentFil
import java.util.List; import java.util.List;
@Deprecated(forRemoval = true)
@SuppressWarnings("unused") @SuppressWarnings("unused")
public final class HideCreateButtonPatch { public final class HideCreateButtonPatch {

View File

@@ -1,18 +1,11 @@
package app.revanced.extension.spotify.misc.privacy; package app.revanced.extension.spotify.misc.privacy;
import android.net.Uri; import app.revanced.extension.shared.privacy.LinkSanitizer;
import java.util.List;
import app.revanced.extension.shared.Logger;
@SuppressWarnings("unused") @SuppressWarnings("unused")
public final class SanitizeSharingLinksPatch { public final class SanitizeSharingLinksPatch {
/** private static final LinkSanitizer sanitizer = new LinkSanitizer(
* Parameters that are considered undesirable and should be stripped away.
*/
private static final List<String> SHARE_PARAMETERS_TO_REMOVE = List.of(
"si", // Share tracking parameter. "si", // Share tracking parameter.
"utm_source" // Share source, such as "copy-link". "utm_source" // Share source, such as "copy-link".
); );
@@ -20,25 +13,7 @@ public final class SanitizeSharingLinksPatch {
/** /**
* Injection point. * Injection point.
*/ */
public static String sanitizeUrl(String url) { public static String sanitizeSharingLink(String url) {
try { return sanitizer.sanitizeUrlString(url);
Uri uri = Uri.parse(url);
Uri.Builder builder = uri.buildUpon().clearQuery();
for (String paramName : uri.getQueryParameterNames()) {
if (!SHARE_PARAMETERS_TO_REMOVE.contains(paramName)) {
for (String value : uri.getQueryParameters(paramName)) {
builder.appendQueryParameter(paramName, value);
}
}
}
String sanitizedUrl = builder.build().toString();
Logger.printInfo(() -> "Sanitized url " + url + " to " + sanitizedUrl);
return sanitizedUrl;
} catch (Exception ex) {
Logger.printException(() -> "sanitizeUrl failure with " + url, ex);
return url;
}
} }
} }

View File

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

View File

@@ -121,7 +121,7 @@ public class DownloadPathPreference extends DialogPreference {
} }
private int findIndexOf(String str) { 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; if (str.equals(entryValues[i])) return i;
} }
return -1; return -1;

View File

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

View File

@@ -2,19 +2,22 @@ package app.revanced.extension.tiktok.settings.preference;
import android.preference.Preference; import android.preference.Preference;
import android.preference.PreferenceScreen; import android.preference.PreferenceScreen;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.Setting; import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment; import app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment;
import app.revanced.extension.tiktok.settings.preference.categories.DownloadsPreferenceCategory; import app.revanced.extension.tiktok.settings.preference.categories.DownloadsPreferenceCategory;
import app.revanced.extension.tiktok.settings.preference.categories.FeedFilterPreferenceCategory;
import app.revanced.extension.tiktok.settings.preference.categories.ExtensionPreferenceCategory; import app.revanced.extension.tiktok.settings.preference.categories.ExtensionPreferenceCategory;
import app.revanced.extension.tiktok.settings.preference.categories.FeedFilterPreferenceCategory;
import app.revanced.extension.tiktok.settings.preference.categories.SimSpoofPreferenceCategory; import app.revanced.extension.tiktok.settings.preference.categories.SimSpoofPreferenceCategory;
/** /**
* Preference fragment for ReVanced settings * Preference fragment for ReVanced settings
*/ */
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
public class ReVancedPreferenceFragment extends AbstractPreferenceFragment { public class TikTokPreferenceFragment extends AbstractPreferenceFragment {
@Override @Override
protected void syncSettingWithPreference(@NonNull Preference pref, protected void syncSettingWithPreference(@NonNull Preference pref,
@@ -37,10 +40,14 @@ public class ReVancedPreferenceFragment extends AbstractPreferenceFragment {
// Currently no resources can be compiled for TikTok (fails with aapt error). // Currently no resources can be compiled for TikTok (fails with aapt error).
// So all TikTok Strings are hard coded in the extension. // So all TikTok Strings are hard coded in the extension.
restartDialogTitle = "Refresh and restart"; restartDialogTitle = "Restart required";
restartDialogMessage = "Restart the app for this change to take effect.";
restartDialogButtonText = "Restart"; restartDialogButtonText = "Restart";
confirmDialogTitle = "Do you wish to proceed?"; confirmDialogTitle = "Do you wish to proceed?";
// App does not use dark mode.
Utils.setIsDarkModeEnabled(false);
PreferenceScreen preferenceScreen = getPreferenceManager().createPreferenceScreen(context); PreferenceScreen preferenceScreen = getPreferenceManager().createPreferenceScreen(context);
setPreferenceScreen(preferenceScreen); setPreferenceScreen(preferenceScreen);

View File

@@ -23,6 +23,12 @@ public class ExtensionPreferenceCategory extends ConditionalPreferenceCategory {
public void addPreferences(Context context) { public void addPreferences(Context context) {
addPreference(new ReVancedTikTokAboutPreference(context)); addPreference(new ReVancedTikTokAboutPreference(context));
addPreference(new TogglePreference(context,
"Sanitize sharing links",
"Remove tracking parameters from shared links.",
BaseSettings.SANITIZE_SHARED_LINKS
));
addPreference(new TogglePreference(context, addPreference(new TogglePreference(context,
"Enable debug log", "Enable debug log",
"Show extension debug log.", "Show extension debug log.",

View File

@@ -0,0 +1,29 @@
package app.revanced.extension.tiktok.share;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.privacy.LinkSanitizer;
import app.revanced.extension.shared.settings.BaseSettings;
@SuppressWarnings("unused")
public final class ShareUrlSanitizer {
private static final LinkSanitizer sanitizer = new LinkSanitizer();
/**
* Injection point for setting check.
*/
public static boolean shouldSanitize() {
return BaseSettings.SANITIZE_SHARED_LINKS.get();
}
/**
* Injection point for URL sanitization.
*/
public static String sanitizeShareUrl(final String url) {
if (url == null || url.isEmpty()) {
return url;
}
return sanitizer.sanitizeUrlString(url);
}
}

View File

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

View File

@@ -7,6 +7,7 @@ import android.util.AttributeSet;
import android.view.View; import android.view.View;
import android.widget.TextView; import android.widget.TextView;
@SuppressWarnings({"deprecation", "unused"})
public class CustomPreferenceCategory extends PreferenceCategory { public class CustomPreferenceCategory extends PreferenceCategory {
public CustomPreferenceCategory(Context context, AttributeSet attrs) { public CustomPreferenceCategory(Context context, AttributeSet attrs) {
super(context, 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; 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 @Override
protected void initialize() { protected void initialize() {

View File

@@ -1 +1,3 @@
// Do not remove. Necessary for the extension plugin to be applied to the project. dependencies {
compileOnly(project(":extensions:shared:library"))
}

View File

@@ -1,16 +1,30 @@
package app.revanced.twitter.patches.links; package app.revanced.twitter.patches.links;
@SuppressWarnings("unused")
public final class ChangeLinkSharingDomainPatch { public final class ChangeLinkSharingDomainPatch {
private static final String DOMAIN_NAME = "https://fxtwitter.com"; private static final String LINK_FORMAT = "https://%s/%s/status/%s";
private static final String LINK_FORMAT = "%s/%s/status/%s";
/**
* Method is modified during patching. Do not change.
*/
private static String getShareDomain() {
return "";
}
// TODO remove this once changeLinkSharingDomainResourcePatch is restored
/**
* Injection point.
*/
public static String formatResourceLink(Object... formatArgs) { public static String formatResourceLink(Object... formatArgs) {
String username = (String) formatArgs[0]; String username = (String) formatArgs[0];
String tweetId = (String) formatArgs[1]; String tweetId = (String) formatArgs[1];
return String.format(LINK_FORMAT, DOMAIN_NAME, username, tweetId); return String.format(LINK_FORMAT, getShareDomain(), username, tweetId);
} }
/**
* Injection point.
*/
public static String formatLink(long tweetId, String username) { public static String formatLink(long tweetId, String username) {
return String.format(LINK_FORMAT, DOMAIN_NAME, username, tweetId); return String.format(LINK_FORMAT, getShareDomain(), username, tweetId);
} }
} }

View File

@@ -2,11 +2,18 @@ package app.revanced.twitter.patches.links;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.util.Log;
import app.revanced.extension.shared.Logger;
@SuppressWarnings("unused")
@Deprecated(forRemoval = true)
public final class OpenLinksWithAppChooserPatch { public final class OpenLinksWithAppChooserPatch {
/**
* Injection point.
*/
public static void openWithChooser(final Context context, final Intent intent) { public static void openWithChooser(final Context context, final Intent intent) {
Log.d("ReVanced", "Opening intent with chooser: " + intent); Logger.printInfo(() -> "Opening intent with chooser: " + intent);
intent.setAction("android.intent.action.VIEW"); intent.setAction("android.intent.action.VIEW");

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