backup repo
Android CI / build (push) Has been cancelled

This commit is contained in:
Zhaohui Huang committed 2026-07-06 19:09:41 -11:00
commit d5d3e61e30
147 files changed
+25311

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
name: 反馈 Bug
description: 反馈遇到的问题
labels: [bug]
title: "[Bug] "
body:
- type: markdown
attributes:
value: |
为了使我们更好地帮助你,请提供以下信息。
- type: textarea
id: desc
attributes:
label: 问题描述
description: 发生了什么情况?有什么现状?
validations:
required: true
- type: textarea
id: steps
attributes:
label: 复现步骤
description: 如何复现
placeholder: |
1. 打开...
2. 点击...
3. 出现...状况
validations:
required: true
- type: textarea
id: expected
attributes:
label: 预期行为
description: 正常情况下应该发生什么
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际行为
description: 实际上发生了什么
validations:
required: true
- type: textarea
id: media
attributes:
label: 截图或录屏
description: 问题复现时候的截图或录屏
placeholder: 点击文本框下面小长条可以上传文件
- type: input
id: android-ver
attributes:
label: 安卓版本
placeholder: "12"
validations:
required: true
- type: input
id: romaing-ver
attributes:
label: 哔哩漫游版本
placeholder: 1.6.2
validations:
required: true
- type: dropdown
id: client
attributes:
label: 哔哩哔哩版本
options:
- 粉版(普通版)
- 概念版
- HD 版
- play 版
description: 目前仅支持粉版、概念版、HD 版和 play 版
validations:
required: true
- type: input
id: client-ver
attributes:
label: 哔哩哔哩版本号
description: 非最新版本可能不受理
placeholder: 6.74.0
validations:
required: true
- type: input
id: framework
attributes:
label: 使用的框架和版本
placeholder: LSPosed 1.8.2
validations:
required: true
- type: textarea
id: misc
attributes:
label: 其他
description: 如哪部番、Magisk 版本等
- type: textarea
id: logs
attributes:
label: 日志
description: 请使用漫游自带的导出日志功能或者使用 `adb logcat`。无日志提交会被关闭。
placeholder: 点击文本框下面小长条可以上传文件
validations:
required: true
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Telegram 频道
url: https://t.me/biliroaming
about: 可以订阅更新、讨论交流
- name: QQ 频道
url: https://qun.qq.com/qqweb/qunpro/share?_wv=3&_wwv=128&inviteCode=NVoD5&from=246610&biz=ka
about: 可以订阅更新、讨论交流
@@ -0,0 +1,31 @@
name: 功能请求
description: 想要请求添加某个功能
labels: [enhancement]
title: "[Feature] "
body:
- type: textarea
id: reason
attributes:
label: 原因
description: 为什么想要这个功能
validations:
required: true
- type: textarea
id: desc
attributes:
label: 功能简述
description: 想要个怎样的功能
validations:
required: true
- type: textarea
id: logic
attributes:
label: 功能逻辑
description: 如何互交、如何使用等
validations:
required: true
- type: textarea
id: ref
attributes:
label: 实现参考
description: 该功能可能的实现方式,或者其他已经实现该功能的应用等
+40
View File
@@ -0,0 +1,40 @@
name: 添加公共服务器
description: 把自己的服务器添加到公共服务器列表
labels: [server]
title: "[Server] "
body:
- type: input
id: contact
attributes:
label: 联系方式
description: Telegram、Github 账号等联系方式
validations:
required: true
- type: input
id: domain
attributes:
label: 域名
description: 服务器域名
validations:
required: true
- type: checkboxes
id: areas
attributes:
label: 支持地区
description: 服务器支持解析的地区
options:
- label: 中国大陆
- label: 港澳
- label: 台湾
- label: 东南亚
- type: checkboxes
id: premium
attributes:
label: 带会员专享
options:
- label: 服务器只有带会员用户能解析
- type: input
id: sponsor
attributes:
label: 赞助地址
description: 爱发电等
+23
View File
@@ -0,0 +1,23 @@
version: 2
updates:
- package-ecosystem: gradle
directory: "/"
schedule:
interval: daily
time: "21:00"
open-pull-requests-limit: 10
target-branch: master
registries:
- maven-google
- gralde-plugin
groups:
maven-dependencies:
patterns:
- "*"
registries:
maven-google:
type: maven-repository
url: "https://dl.google.com/dl/android/maven2/"
gralde-plugin:
type: maven-repository
url: "https://plugins.gradle.org/m2/"
+52
View File
@@ -0,0 +1,52 @@
name: PR Build
on: [pull_request]
jobs:
build:
name: Build on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_COMPILERCHECK: "%compiler% -dumpmachine; %compiler% -dumpversion"
CCACHE_NOHASHDIR: true
CCACHE_MAXSIZE: 1G
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
steps:
- name: Check out
uses: actions/checkout@v3
with:
submodules: 'recursive'
fetch-depth: 0
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Set up ccache
uses: hendrikmuhs/ccache-action@v1.2
with:
key: ${{ runner.os }}-${{ github.sha }}
restore-keys: ${{ runner.os }}
- name: Build with Gradle
run: |
echo 'org.gradle.caching=true' >> gradle.properties
echo 'org.gradle.parallel=true' >> gradle.properties
echo 'org.gradle.vfs.watch=true' >> gradle.properties
echo 'org.gradle.jvmargs=-Xmx2048m' >> gradle.properties
echo 'android.native.buildOutput=verbose' >> gradle.properties
./gradlew assemble
- name: Stop gradle daemon
run: ./gradlew --stop
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.os }}-artifact
path: |
app/build/outputs
app/release
+77
View File
@@ -0,0 +1,77 @@
name: Android CI
on:
push:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_COMPILERCHECK: "%compiler% -dumpmachine; %compiler% -dumpversion"
CCACHE_NOHASHDIR: true
CCACHE_MAXSIZE: 1G
steps:
- uses: actions/checkout@v3
with:
submodules: 'recursive'
fetch-depth: 0
- name: Setup JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: 17
cache: 'gradle'
- name: Retrieve version
run: |
echo VERSION=$(echo ${{ github.event.head_commit.id }} | head -c 10) >> $GITHUB_ENV
- name: Set up ccache
uses: hendrikmuhs/ccache-action@v1.2
with:
key: ${{ runner.os }}-${{ github.sha }}
restore-keys: ${{ runner.os }}
- name: Write key
if: ${{ ( github.event_name != 'pull_request' && github.ref == 'refs/heads/master' ) || github.ref_type == 'tag' }}
run: |
if [ ! -z "${{ secrets.SIGNING_KEY }}" ]; then
echo 'Writing sign key'
echo releaseStorePassword='${{ secrets.KEY_STORE_PASSWORD }}' >> gradle.properties
echo releaseKeyAlias='${{ secrets.ALIAS }}' >> gradle.properties
echo releaseKeyPassword='${{ secrets.KEY_PASSWORD }}' >> gradle.properties
echo releaseStoreFile='key.jks' >> gradle.properties
echo '${{ secrets.SIGNING_KEY }}' | base64 --decode > key.jks
fi
- name: Build with Gradle
run: |
echo 'org.gradle.caching=true' >> gradle.properties
echo 'org.gradle.parallel=true' >> gradle.properties
echo 'org.gradle.vfs.watch=true' >> gradle.properties
echo 'org.gradle.jvmargs=-Xmx2048m' >> gradle.properties
echo 'android.native.buildOutput=verbose' >> gradle.properties
./gradlew -PappVerName=${{ env.VERSION }} assembleRelease assembleDebug
- name: Upload built apk
if: success()
uses: actions/upload-artifact@v4
with:
name: snapshot
path: |
app/build/outputs/apk
app/build/outputs/mapping
app/release
- name: Post to channel
if: github.ref == 'refs/heads/master'
env:
CHANNEL_ID: ${{ secrets.TELEGRAM_TO }}
BOT_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
FILE: app/release/BiliRoaming_${{ env.VERSION }}.apk
COMMIT_MESSAGE: |+
New push to github\!
```
${{ github.event.head_commit.message }}
```by `${{ github.event.head_commit.author.name }}`
See commit detail [here](${{ github.event.head_commit.url }})
Snapshot apk is attached \(unsupported by TAICHI\)
run: |
ESCAPED=`python3 -c 'import json,os,urllib.parse; print(urllib.parse.quote(json.dumps(os.environ["COMMIT_MESSAGE"])))'`
curl -v "https://api.telegram.org/bot${BOT_TOKEN}/sendMediaGroup?chat_id=${CHANNEL_ID}&media=%5B%7B%22type%22:%22document%22,%20%22media%22:%22attach://release%22,%22parse_mode%22:%22MarkdownV2%22,%22caption%22:${ESCAPED}%7D%5D" -F release="@$FILE"
+11
View File
@@ -0,0 +1,11 @@
*.iml
.gradle
/local.properties
.idea
.DS_Store
/build
/captures
.externalNativeBuild
*.apk
*.jar
/.kotlin
+3
View File
@@ -0,0 +1,3 @@
[submodule "app/src/main/jni/dex_builder"]
path = app/src/main/jni/dex_builder
url = git@github.com:LSPosed/DexBuilder.git
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
{one line to give the program's name and a brief idea of what it does.}
Copyright (C) {year} {name of author}
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
{project} Copyright (C) {year} {fullname}
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
+77
View File
@@ -0,0 +1,77 @@
<div align="center">
<img width="160" src="imgs/icon.png" alt="logo">
Logo来自[哔哩哔哩漫游娘](https://www.weibo.com/p/230418139a6f1100102vlj6)
[![Android CI](https://github.com/yujincheng08/BiliRoaming/workflows/Android%20CI/badge.svg)](https://github.com/yujincheng08/BiliRoaming/actions)
[![Chat](https://img.shields.io/badge/Join-QQ%E9%A2%91%E9%81%93-red?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjEgMTQzLjg5Ij48cGF0aCBmaWxsPSIjZmFhYjA3IiBkPSJNNjAuNTAzIDE0Mi4yMzdjLTEyLjUzMyAwLTI0LjAzOC00LjE5NS0zMS40NDUtMTAuNDYtMy43NjIgMS4xMjQtOC41NzQgMi45MzItMTEuNjEgNS4xNzUtMi42IDEuOTE4LTIuMjc1IDMuODc0LTEuODA3IDQuNjYzIDIuMDU2IDMuNDcgMzUuMjczIDIuMjE2IDQ0Ljg2MiAxLjEzNnptMCAwYzEyLjUzNSAwIDI0LjAzOS00LjE5NSAzMS40NDctMTAuNDYgMy43NiAxLjEyNCA4LjU3MyAyLjkzMiAxMS42MSA1LjE3NSAyLjU5OCAxLjkxOCAyLjI3NCAzLjg3NCAxLjgwNSA0LjY2My0yLjA1NiAzLjQ3LTM1LjI3MiAyLjIxNi00NC44NjIgMS4xMzZ6bTAgMCIvPjxwYXRoIGQ9Ik02MC41NzYgNjcuMTE5YzIwLjY5OC0uMTQgMzcuMjg2LTQuMTQ3IDQyLjkwNy01LjY4MyAxLjM0LS4zNjcgMi4wNTYtMS4wMjQgMi4wNTYtMS4wMjQuMDA1LS4xODkuMDg1LTMuMzcuMDg1LTUuMDFDMTA1LjYyNCAyNy43NjggOTIuNTguMDAxIDYwLjUgMCAyOC40Mi4wMDEgMTUuMzc1IDI3Ljc2OSAxNS4zNzUgNTUuNDAxYzAgMS42NDIuMDggNC44MjIuMDg2IDUuMDEgMCAwIC41ODMuNjE1IDEuNjUuOTEzIDUuMTkgMS40NDQgMjIuMDkgNS42NSA0My4zMTIgNS43OTV6bTU2LjI0NSAyMy4wMmMtMS4yODMtNC4xMjktMy4wMzQtOC45NDQtNC44MDgtMTMuNTY4IDAgMC0xLjAyLS4xMjYtMS41MzcuMDIzLTE1LjkxMyA0LjYyMy0zNS4yMDIgNy41Ny00OS45IDcuMzkyaC0uMTUzYy0xNC42MTYuMTc1LTMzLjc3NC0yLjczNy00OS42MzQtNy4zMTUtLjYwNi0uMTc1LTEuODAyLS4xLTEuODAyLS4xLTEuNzc0IDQuNjI0LTMuNTI1IDkuNDQtNC44MDggMTMuNTY4LTYuMTE5IDE5LjY5LTQuMTM2IDI3LjgzOC0yLjYyNyAyOC4wMiAzLjIzOS4zOTIgMTIuNjA2LTE0LjgyMSAxMi42MDYtMTQuODIxIDAgMTUuNDU5IDEzLjk1NyAzOS4xOTUgNDUuOTE4IDM5LjQxM2guODQ4YzMxLjk2LS4yMTggNDUuOTE3LTIzLjk1NCA0NS45MTctMzkuNDEzIDAgMCA5LjM2OCAxNS4yMTMgMTIuNjA3IDE0LjgyMiAxLjUwOC0uMTgzIDMuNDkxLTguMzMyLTIuNjI3LTI4LjAyMSIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00OS4wODUgNDAuODI0Yy00LjM1Mi4xOTctOC4wNy00Ljc2LTguMzA0LTExLjA2My0uMjM2LTYuMzA1IDMuMDk4LTExLjU3NiA3LjQ1LTExLjc3MyA0LjM0Ny0uMTk1IDguMDY0IDQuNzYgOC4zIDExLjA2NS4yMzggNi4zMDYtMy4wOTcgMTEuNTc3LTcuNDQ2IDExLjc3MW0zMS4xMzMtMTEuMDYzYy0uMjMzIDYuMzAyLTMuOTUxIDExLjI2LTguMzAzIDExLjA2My00LjM1LS4xOTUtNy42ODQtNS40NjUtNy40NDYtMTEuNzcuMjM2LTYuMzA1IDMuOTUyLTExLjI2IDguMy0xMS4wNjYgNC4zNTIuMTk3IDcuNjg2IDUuNDY4IDcuNDQ5IDExLjc3MyIvPjxwYXRoIGZpbGw9IiNmYWFiMDciIGQ9Ik04Ny45NTIgNDkuNzI1Qzg2Ljc5IDQ3LjE1IDc1LjA3NyA0NC4yOCA2MC41NzggNDQuMjhoLS4xNTZjLTE0LjUgMC0yNi4yMTIgMi44Ny0yNy4zNzUgNS40NDZhLjg2My44NjMgMCAwMC0uMDg1LjM2Ny44OC44OCAwIDAwLjE2LjQ5NmMuOTggMS40MjcgMTMuOTg1IDguNDg3IDI3LjMgOC40ODdoLjE1NmMxMy4zMTQgMCAyNi4zMTktNy4wNTggMjcuMjk5LTguNDg3YS44NzMuODczIDAgMDAuMTYtLjQ5OC44NTYuODU2IDAgMDAtLjA4NS0uMzY1Ii8+PHBhdGggZD0iTTU0LjQzNCAyOS44NTRjLjE5OSAyLjQ5LTEuMTY3IDQuNzAyLTMuMDQ2IDQuOTQzLTEuODgzLjI0Mi0zLjU2OC0xLjU4LTMuNzY4LTQuMDctLjE5Ny0yLjQ5MiAxLjE2Ny00LjcwNCAzLjA0My00Ljk0NCAxLjg4Ni0uMjQ0IDMuNTc0IDEuNTggMy43NzEgNC4wN20xMS45NTYuODMzYy4zODUtLjY4OSAzLjAwNC00LjMxMiA4LjQyNy0yLjk5MyAxLjQyNS4zNDcgMi4wODQuODU3IDIuMjIzIDEuMDU3LjIwNS4yOTYuMjYyLjcxOC4wNTMgMS4yODYtLjQxMiAxLjEyNi0xLjI2MyAxLjA5NS0xLjczNC44NzUtLjMwNS0uMTQyLTQuMDgyLTIuNjYtNy41NjIgMS4wOTctLjI0LjI1Ny0uNjY4LjM0Ni0xLjA3My4wNC0uNDA3LS4zMDgtLjU3NC0uOTMtLjMzNC0xLjM2MiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik02MC41NzYgODMuMDhoLS4xNTNjLTkuOTk2LjEyLTIyLjExNi0xLjIwNC0zMy44NTQtMy41MTgtMS4wMDQgNS44MTgtMS42MSAxMy4xMzItMS4wOSAyMS44NTMgMS4zMTYgMjIuMDQzIDE0LjQwNyAzNS45IDM0LjYxNCAzNi4xaC44MmMyMC4yMDgtLjIgMzMuMjk4LTE0LjA1NyAzNC42MTYtMzYuMS41Mi04LjcyMy0uMDg3LTE2LjAzNS0xLjA5Mi0yMS44NTQtMTEuNzM5IDIuMzE1LTIzLjg2MiAzLjY0LTMzLjg2IDMuNTE4Ii8+PHBhdGggZmlsbD0iI2ViMTkyMyIgZD0iTTMyLjEwMiA4MS4yMzV2MjEuNjkzczkuOTM3IDIuMDA0IDE5Ljg5My42MTZWODMuNTM1Yy02LjMwNy0uMzU3LTEzLjEwOS0xLjE1Mi0xOS44OTMtMi4zIi8+PHBhdGggZmlsbD0iI2ViMTkyMyIgZD0iTTEwNS41MzkgNjAuNDEycy0xOS4zMyA2LjEwMi00NC45NjMgNi4yNzVoLS4xNTNjLTI1LjU5MS0uMTcyLTQ0Ljg5Ni02LjI1NS00NC45NjItNi4yNzVMOC45ODcgNzYuNTdjMTYuMTkzIDQuODgyIDM2LjI2MSA4LjAyOCA1MS40MzYgNy44NDVoLjE1M2MxNS4xNzUuMTgzIDM1LjI0Mi0yLjk2MyA1MS40MzctNy44NDV6bTAgMCIvPjwvc3ZnPg==)](https://qun.qq.com/qqweb/qunpro/share?_wv=3&_wwv=128&inviteCode=NVoD5&from=246610&biz=ka)
[![Channel](https://img.shields.io/badge/Follow-Telegram-blue.svg?logo=telegram)](https://t.me/biliroaming)
[![Download](https://img.shields.io/github/v/release/yujincheng08/biliroaming?color=critical&label=Downloads&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAF2UlEQVRIiaVWS2xTRxQ9d2bs5y+O4zgEYkgoShcESIG2EQJRREFAKmABRFCpbOiioumqrNiQCmjFiiB1Q6kqwR6k0NIKUBdFLBAt31BBoUqC8xEhgXwcO7bfezPVTJyQkBA+vdKT5fdm7rn3zL3nDilMtlt1dRiOx+G3bSjO4TIGSLkOrrtJMfYBgEoA0cKmAVKqHUR/EXBBSnmJ53JQHg9UIIDA06dY3NwMmoAgMI2NLZDAXuW6XwGogQaeahHFWIUCPiKlvgZjLVKI7wn4gdSLqYzaFC96oSJ612HsiqvUjwZsJlMKE5wvkV7vCVeIq4poEU0I/jlgKATzhMOAEADRZunx3FVEq15c/DpmwIlq80LcsYGthhnLArxe85DasMFEqT/0BAIb7oVCFy3GQFK+Bdxzk4xB2jbmSVkXFOI3WWBBdEmpKYRDNK8rGr3Iddr5vHk3TjPnsAcH4aTTsEpKwDwenQVkLodcXx9EOAzPrFlQrju+h7suyONBq8/366yBgYWW67YaSnuKi/EkGkVnWdkvOifvRDAiEGPIJJPwRqMoWbUKJISJXIMxvx+l69bBE4kg/egRSO8r7NU+NEteXbVCnBfDw+CpFPiemhpIzj8lxvZ5HGdyZoxhuK0NsdpaLG5sxNy6OqQePMBASwucTAbFK1Zg0YEDiK9ejZGuLgzcuQNvUdEkarlScBgryVhW+0godJvpKIjoWzZanZNo1FHHVq5EzdGjhkpzBsGgoU4pNUotYL4tPXIEpWvXIqMz5XzcjyoUEvd4vrOIwPyMrVZEFeqFvrGHhoyjJY2Nk4vBtk3mmr6JZ6Zt8cGD8CcSyPf3T3pPpnvUHJVOf8wcxrabs5qQmTsygv6bN1G+dSu43z9ps/D7IR3HPMLnm+yYc1Ts2oX8s2fTFS6Uz7dDuMCH42BCINvdDR4KoaqhAXO3bDHvc6kUnnZ0AJyjv70dVjhsMhzo6EDX/fsg10VxeTl8RUWILl9uisgUle6/Md9SwhVihQBRhVELzjHS3Y1AeTmqDx5EsKJifPFQMokLu3fDF4thTiyGcDxuziadTOJKQwNSnZ3YfOoUymtr4S0uNi2SevgQwfnzIXS7OM5o9SpVzj9fuvQb3Q0ymzXOlx8/bkAnWjAeR0Sf69WrCCUScHW0uuQtCyKZRM2ePajcscM41YWkqzdYWYnBlha46bQpNJOULvwxucv29qJs40b4Zs+eSj4R3tm3DyXr1yPV2mrYYEIg1daGotpaVO3fj4nirsHm19djyeHDUDq4QjIoiPegOVDbRmjBgmkPe8x0FfrmzEH28WOjMN5IBEsOHXrp+kh1tendbE/P2KsUg5SPUFAIO5OZEZAHAqbfck+eIN3aasD0mc1k4YULTTIY7fMuRkL8qXvQikTQcfnyjJu1hauqsOzYMSxrakJRzcyTS1umr8/QrRjT+nqdsWz2jEa3YjEM3LiB66dPv9JJfM0alOkp8wpLp9N42NyMoFYpzWI2e4Ypy7pMQnS4SiGeSCB58iT+aGpCX0cHpp/ZrzatP49u3cLvDQ3g/f3gWl+l7FFCXKKr9fX6z2fSsk5zIUC2ja72duRLShBMJEw1vskg1kE62SwybW3Q6htNJJB1XXhcdy9X6ie6tnOn4dj2+/9WjrNIEMHDGHLpNNLDw6as3xSQcY5wURG4ZSHrOGC53L/efL5K0yr8paWGX18+/8mAZbXpsaOVgfl8iLygo28CqgPNOQ7cYBBWMFjH9KDXzT9SWWkW6QnwJB6va6uuPq/n4v+9YuhRZ+dyqLSsbdFY7NzYZJlyL729bduWodLSZjEyQm9ziRrL0A4EEO7t3b7s3LmzBR0136ZcE0U2+zPL55cCuIa3gCxcLW5wovc452ehM9PirX9ddyqg1NNayrtcqVqu1BcA7r0OSCG4f8hxvmSOs4KA29O11bQ377HMSKkTRHRCEW0iKTcpovcBzNMyWVipdbiTue51kvICgPPm5F/GDID/AISQbRffDZUGAAAAAElFTkSuQmCC)](https://github.com/yujincheng08/BiliRoaming/releases/latest)
[![Stars](https://img.shields.io/github/stars/yujincheng08/biliroaming?label=Stars&color=important&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAdCAYAAAC5UQwxAAADKUlEQVRIib2WX0iTURTAz737tpWEissM6c9cOUUkCqmHpYEtfAiFyl4yCoQefOmhHozAECGweskMw+hRitRQsD8ULSl0H1mUIDRr5myVpgvJQvP7f+P7Nsfc9s3dMTpw4ePee87vnHvvOeeD/y2IEEKNFOYHMom8lGHedGKWVjcloDJu7QLxRz7exTtpdRlqmurlot+KAHAqutRKsu/YeaQABgIge/e30upTR2hY6K8FEzhADfE3q9DqU0Uo+uoaQFCpQU01UmXS2UJjg+7RjCI3EHBoQFUIABFhGO0lFcmaSDpC6cuZ01p0kZcQilL21TQmayfpCMkoGkIA5TEuKlqkLL/dVWG2ONe80xggH7iXj4XPdiz5rUicKgDBZ8OC36Y+EsDggGj/1HlZ+2KJectXhSnwEaN1Ckw2n8zs8JrzTn1ftZ2bbjeb5i42gwHKkLy0QVNWwBE2hiNGIlEixopTGFjtvg0Zf4kEb+W8C1e1CCVP2XXm1/t9kAGO1NI5gajwJWBJVqEXlXrrNfNMybtzYu6RXuCBTTMOgAOW5FYOqjCIfKVGe3+baDnaC8tphC4Dq+Q4Xcg+eGllatUBGgv72kRLbXdaoBrskAvbXc2R0zE3Zix80C5Zjgeh9I0kAlb1DNufN0cv6eahOFnXYFzoPgmMUk4FE9Gwkl39EO8cuBZvOWHiK2NZj7H053C4lK0lMgDBxpdot1CptzNhEmCymKnlYrKiWiNiwg6kC+R/9uWAqGCqvEQASAIszHYWUwOx4CkNVxwaIeBAwoSdGogEb6wSClUOtWvwoe/oI1cbszBeqmdX97yR4C2KcYcL1kcpt/4O4PUcE7h1VqudplBJDDmAhU9F9EDxY3EYKGiFmZWzK11SXlOLOftgsA1t67gvT9Q0GhYeaUcJ5tDfgOS36tkFNS3iDWUUhsgbIOQ1uGXPnhtcoGej3l5u/sk6yeNoJSPgJiNAyDtwc/MvcLy98Q3MdJSQIXArY9YubqbTrgeKHnzgbr78oeQ2eQVu8VtTVbw9cRNfnL58APFzmxnbzR7do0kg4lRjNWGwZNp65Wkq+ukTAPgHIIGzcZjmG+EAAAAASUVORK5CYII=
)](https://github.com/yujincheng08/BiliRoaming)
# 哔哩漫游
解除B站客户端番剧区域限制的Xposed模块,并且提供其他小功能
# BiliRoaming
An Xposed module that unblocks bangumi area limit of BILIBILI, and miscellaneous features
</div>
# 支持以下功能
- 解除B站番剧区域限制
- 港澳台CDN加速
- 缓存番剧
- 支持国际版和概念版
- 自定义主题色
- 关闭青少年模式弹窗
- 显示评论区楼层
- ~概念版添加直播入口~
- 不以小程序形式分享
- 自动点赞视频
- 把我的页面移到侧边栏
- ~替换音乐状态栏为原生样式~
- 提取视频、直播封面
- 自定义屏启动图
# Features
- Unlock bangumi area limit
- CDN speedup
- Download bangumi
- Support International ver and blue ver
- Customize theme
- Close teenager dialog
- Show comment floor
- ~Add live entry to blue ver~
- Share without mini programs
- Like videos automatically
- Move 'Mine page' to sidebar drawer
- ~Change music notification style to Primitive~
- Extra covers from videos and live rooms
- Customized splash images
# download/下载
https://github.com/yujincheng08/BiliRoaming/releases/latest
https://modules.lsposed.org/module/me.iacn.biliroaming
# 使用方法
https://github.com/yujincheng08/BiliRoaming/wiki#%E4%BD%BF%E7%94%A8%E6%96%B9%E6%B3%95
# 特别鸣谢
- [原作者](https://github.com/iAcn/BiliRoaming)
- [BiliPlus](https://www.biliplus.com/)
- [Kghost](https://github.com/kghost/bilibili-area-limit)
### 交流
Telegram: [@biliroaming](https://t.me/biliroaming)
### License
[GNU General Public License, version 3](LICENSE)
### 本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助
[亚洲最佳CDN、边缘和安全解决方案 - Tencent EdgeOne](https://edgeone.ai/zh?from=github)
![](https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png)
+3
View File
@@ -0,0 +1,3 @@
/build
/release
/.cxx
+186
View File
@@ -0,0 +1,186 @@
import com.google.protobuf.gradle.*
plugins {
alias(libs.plugins.agp.app)
alias(libs.plugins.kotlin)
alias(libs.plugins.protobuf)
alias(libs.plugins.lsplugin.resopt)
alias(libs.plugins.lsplugin.jgit)
alias(libs.plugins.lsplugin.apksign)
alias(libs.plugins.lsplugin.apktransform)
alias(libs.plugins.lsplugin.cmaker)
}
val appVerCode = jgit.repo()?.commitCount("refs/remotes/origin/master") ?: 0
val appVerName: String by rootProject
apksign {
storeFileProperty = "releaseStoreFile"
storePasswordProperty = "releaseStorePassword"
keyAliasProperty = "releaseKeyAlias"
keyPasswordProperty = "releaseKeyPassword"
}
apktransform {
copy {
when (it.buildType) {
"release" -> file("${it.name}/BiliRoaming_${appVerName}.apk")
else -> null
}
}
}
cmaker {
default {
targets("biliroaming")
abiFilters("armeabi-v7a", "arm64-v8a", "x86")
arguments += arrayOf(
"-DANDROID_STL=none",
"-DCMAKE_CXX_STANDARD=23",
"-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON",
)
cFlags += "-flto"
cppFlags += "-flto"
}
buildTypes {
arguments += "-DDEBUG_SYMBOLS_PATH=${layout.buildDirectory.file("symbols/${it.name}").get().asFile.absolutePath}"
}
}
android {
namespace = "me.iacn.biliroaming"
compileSdk = 35
buildToolsVersion = "35.0.0"
ndkVersion = "29.0.14206865"
buildFeatures {
prefab = true
buildConfig = true
}
defaultConfig {
applicationId = "me.iacn.biliroaming"
minSdk = 24
targetSdk = 35 // Target Android U
versionCode = appVerCode
versionName = appVerName
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles("proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility(JavaVersion.VERSION_11)
targetCompatibility(JavaVersion.VERSION_11)
}
kotlinOptions {
jvmTarget = "11"
freeCompilerArgs = listOf(
"-Xno-param-assertions",
"-Xno-call-assertions",
"-Xno-receiver-assertions",
"-language-version=2.0",
)
}
sourceSets {
named("main") {
proto {
srcDir("src/main/proto")
include("**/*.proto")
}
}
}
packaging {
resources {
excludes += "**"
}
}
lint {
checkReleaseBuilds = false
}
dependenciesInfo {
includeInApk = false
}
androidResources {
additionalParameters += arrayOf("--allow-reserved-package-id", "--package-id", "0x23")
}
externalNativeBuild {
cmake {
path("src/main/jni/CMakeLists.txt")
version = "4.1.0+"
}
}
}
protobuf {
protoc {
artifact = libs.protobuf.protoc.get().toString()
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
id("java") {
option("lite")
}
id("kotlin") {
option("lite")
}
}
}
}
}
configurations.all {
exclude("org.jetbrains.kotlin", "kotlin-stdlib-jdk7")
exclude("org.jetbrains.kotlin", "kotlin-stdlib-jdk8")
}
dependencies {
compileOnly(libs.xposed)
implementation(libs.protobuf.kotlin)
implementation(libs.protobuf.java)
compileOnly(libs.protobuf.protoc)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlin.coroutines.android)
implementation(libs.kotlin.coroutines.jdk)
implementation(libs.androidx.documentfile)
implementation(libs.cxx)
implementation(libs.okhttp)
}
val adbExecutable: String = androidComponents.sdkComponents.adb.get().asFile.absolutePath
val restartBiliBili = task("restartBiliBili").apply {
doLast {
exec {
commandLine(adbExecutable, "shell", "am", "force-stop", "tv.danmaku.bili")
}
exec {
commandLine(
adbExecutable,
"shell",
"am",
"start",
"$(pm resolve-activity --components tv.danmaku.bili)"
)
}
}
}
afterEvaluate {
tasks.getByPath("installDebug").finalizedBy(restartBiliBili)
}
+41
View File
@@ -0,0 +1,41 @@
-repackageclasses "biliroaming"
-keep class me.iacn.biliroaming.XposedInit {
<init>();
}
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
<fields>;
}
-keepclasseswithmembers class me.iacn.biliroaming.utils.DexHelper {
native <methods>;
long token;
java.lang.ClassLoader classLoader;
}
-keepattributes RuntimeVisible*Annotations
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
-keepclassmembers class * implements android.os.Parcelable {
public static final ** CREATOR;
}
-keepclassmembers class me.iacn.biliroaming.MainActivity$Companion {
boolean isModuleActive();
}
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
public static void check*(...);
public static void throw*(...);
}
-assumenosideeffects class java.util.Objects {
public static ** requireNonNull(...);
}
-allowaccessmodification
-overloadaggressively
+1
View File
@@ -0,0 +1 @@
generated
+59
View File
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
</intent>
</queries>
<application
android:allowBackup="true"
android:fullBackupContent="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/MainTheme"
tools:ignore="GoogleAppIndexingWarning">
<activity-alias
android:name=".MainActivityAlias"
android:exported="true"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity
android:name=".MainActivity"
android:exported="true"
android:showForAllUsers="true"
android:launchMode="singleInstance">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="de.robv.android.xposed.category.MODULE_SETTINGS" />
</intent-filter>
</activity>
<meta-data
android:name="xposedmodule"
android:value="true" />
<meta-data
android:name="xposeddescription"
android:value="@string/xposed_description" />
<meta-data
android:name="xposedminversion"
android:value="53" />
<meta-data
android:name="xposedscope"
android:resource="@array/xposed_scope" />
</application>
</manifest>
+7
View File
@@ -0,0 +1,7 @@
(function(a,b){var c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,A,B,C,D,E,F,G,H=[].indexOf||function(a){for(var b=0,c=this.length;b<c;b++)if(b in this&&this[b]===a)return b;return-1};q=null,q="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:"undefined"!=typeof global?global:a,x=q.document,d="before",c="after",o="readyState",n="addEventListener",m="removeEventListener",h="dispatchEvent",u="XMLHttpRequest",g="fetch",i="FormData",p=["load","loadend","loadstart"],e=["progress","abort","error","timeout"],E="undefined"!=typeof navigator&&navigator.useragent?navigator.userAgent:"",A=parseInt((/msie (\d+)/.exec(E.toLowerCase())||[])[1]),isNaN(A)&&(A=parseInt((/trident\/.*; rv:(\d+)/.exec(E.toLowerCase())||[])[1])),(G=Array.prototype).indexOf||(G.indexOf=function(a){var b,c,d,e;for(b=d=0,e=this.length;d<e;b=++d)if(c=this[b],c===a)return b;return-1}),D=function(a,b){return Array.prototype.slice.call(a,b)},w=function(a){return"returnValue"===a||"totalSize"===a||"position"===a},z=function(a,b){var c;for(c in a)if(a[c],!w(c))try{b[c]=a[c]}catch(a){}return b},B=function(a){return void 0===a?null:a},C=function(a,b,c){var d,e,f,g;for(e=function(a){return function(d){var e,f,g;e={};for(f in d)w(f)||(g=d[f],e[f]=g===b?c:g);return c[h](a,e)}},f=0,g=a.length;f<g;f++)d=a[f],c._has(d)&&(b["on"+d]=e(d))},y=function(a){var b;if(x&&null!=x.createEventObject)return b=x.createEventObject(),b.type=a,b;try{return new Event(a)}catch(b){return{type:a}}},f=function(a){var c,d,e;return d={},e=function(a){return d[a]||[]},c={},c[n]=function(a,c,f){d[a]=e(a),d[a].indexOf(c)>=0||(f=f===b?d[a].length:f,d[a].splice(f,0,c))},c[m]=function(a,c){var f;if(a===b)return void(d={});c===b&&(d[a]=[]),f=e(a).indexOf(c),f!==-1&&e(a).splice(f,1)},c[h]=function(){var b,d,f,g,h,i,j,k;for(b=D(arguments),d=b.shift(),a||(b[0]=z(b[0],y(d))),g=c["on"+d],g&&g.apply(c,b),k=e(d).concat(e("*")),f=i=0,j=k.length;i<j;f=++i)h=k[f],h.apply(c,b)},c._has=function(a){return!(!d[a]&&!c["on"+a])},a&&(c.listeners=function(a){return D(e(a))},c.on=c[n],c.off=c[m],c.fire=c[h],c.once=function(a,b){var d;return d=function(){return c.off(a,d),b.apply(null,arguments)},c.on(a,d)},c.destroy=function(){return d={}}),c},F=f(!0),F.EventEmitter=f,F[d]=function(a,b){if(a.length<1||a.length>2)throw"invalid hook";return F[n](d,a,b)},F[c]=function(a,b){if(a.length<2||a.length>3)throw"invalid hook";return F[n](c,a,b)},F.enable=function(){q[u]=t,"function"==typeof r&&(q[g]=r),k&&(q[i]=s)},F.disable=function(){q[u]=F[u],q[g]=F[g],k&&(q[i]=k)},v=F.headers=function(a,b){var c,d,e,f,g,h,i,j,k;switch(null==b&&(b={}),typeof a){case"object":d=[];for(e in a)g=a[e],f=e.toLowerCase(),d.push(f+":\t"+g);return d.join("\n")+"\n";case"string":for(d=a.split("\n"),i=0,j=d.length;i<j;i++)c=d[i],/([^:]+):\s*(.+)/.test(c)&&(f=null!=(k=RegExp.$1)?k.toLowerCase():void 0,h=RegExp.$2,null==b[f]&&(b[f]=h));return b}},k=q[i],s=function(a){var b;this.fd=a?new k(a):new k,this.form=a,b=[],Object.defineProperty(this,"entries",{get:function(){var c;return c=a?D(a.querySelectorAll("input,select")).filter(function(a){var b;return"checkbox"!==(b=a.type)&&"radio"!==b||a.checked}).map(function(a){return[a.name,"file"===a.type?a.files:a.value]}):[],c.concat(b)}}),this.append=function(a){return function(){var c;return c=D(arguments),b.push(c),a.fd.append.apply(a.fd,c)}}(this)},k&&(F[i]=k,q[i]=s),l=q[u],F[u]=l,t=q[u]=function(){var a,b,g,i,j,k,l,m,q,r,t,w,x,y,D,E,G,I,J,K,L;a=-1,I=new F[u],t={},y=null,l=void 0,D=void 0,w=void 0,r=function(){var b,c,d,e;if(w.status=y||I.status,y===a&&A<10||(w.statusText=I.statusText),y!==a){e=v(I.getAllResponseHeaders());for(b in e)d=e[b],w.headers[b]||(c=b.toLowerCase(),w.headers[c]=d)}},q=function(){if(I.responseType&&"text"!==I.responseType)"document"===I.responseType?(w.xml=I.responseXML,w.data=I.responseXML):w.data=I.response;else{w.text=I.responseText,w.data=I.responseText;try{w.xml=I.responseXML}catch(a){}}"responseURL"in I&&(w.finalUrl=I.responseURL)},G=function(){k.status=w.status,k.statusText=w.statusText},E=function(){"text"in w&&(k.responseText=w.text),"xml"in w&&(k.responseXML=w.xml),"data"in w&&(k.response=w.data),"finalUrl"in w&&(k.responseURL=w.finalUrl)},i=function(a){for(;a>b&&b<4;)k[o]=++b,1===b&&k[h]("loadstart",{}),2===b&&G(),4===b&&(G(),E()),k[h]("readystatechange",{}),4===b&&(t.async===!1?g():setTimeout(g,0))},g=function(){l||k[h]("load",{}),k[h]("loadend",{}),l&&(k[o]=0)},b=0,x=function(a){var b,d;if(4!==a)return void i(a);b=F.listeners(c),(d=function(){var a;return b.length?(a=b.shift(),2===a.length?(a(t,w),d()):3===a.length&&t.async?a(t,w,d):d()):i(4)})()},k=t.xhr=f(),I.onreadystatechange=function(a){try{2===I[o]&&r()}catch(a){}4===I[o]&&(D=!1,r(),q()),x(I[o])},m=function(){l=!0},k[n]("error",m),k[n]("timeout",m),k[n]("abort",m),k[n]("progress",function(){b<3?x(3):k[h]("readystatechange",{})}),("withCredentials"in I||F.addWithCredentials)&&(k.withCredentials=!1),k.status=0,L=e.concat(p);for(J=0,K=L.length;J<K;J++)j=L[J],k["on"+j]=null;return kLine truncated
window.XMLHttpRequest.prototype.addEventListener = xhook.addEventListener;
xhook.after(function(request, response) {
response.text = hooker.hook(request.url, response.text)
});
+1
View File
@@ -0,0 +1 @@
me.iacn.biliroaming.XposedInit
@@ -0,0 +1,111 @@
package me.iacn.biliroaming
import android.app.AlertDialog
import android.content.Context
import android.graphics.Color
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.widget.EditText
import android.widget.SeekBar
import android.widget.SeekBar.OnSeekBarChangeListener
import android.widget.TextView
import me.iacn.biliroaming.utils.inflateLayout
/**
* Created by iAcn on 2019/7/14
* Email i@iacn.me
*
* Copy & Modify from ColorChooseDialog on 2021/7/6
*/
class ARGBColorChooseDialog(context: Context, defColor: Int) : AlertDialog.Builder(context) {
private val view = context.inflateLayout(R.layout.dialog_argb_color_choose)
private val sampleView: View = view.findViewById(R.id.view_sample2)
private val etColor: EditText = view.findViewById(R.id.et_color2)
private val sbColorA: SeekBar = view.findViewById(R.id.sb_colorA2)
private val sbColorR: SeekBar = view.findViewById(R.id.sb_colorR2)
private val sbColorG: SeekBar = view.findViewById(R.id.sb_colorG2)
private val sbColorB: SeekBar = view.findViewById(R.id.sb_colorB2)
private val tvColorA: TextView = view.findViewById(R.id.tv_colorA2)
private val tvColorR: TextView = view.findViewById(R.id.tv_colorR2)
private val tvColorG: TextView = view.findViewById(R.id.tv_colorG2)
private val tvColorB: TextView = view.findViewById(R.id.tv_colorB2)
val color: Int
get() = Color.argb(
sbColorA.progress,
sbColorR.progress,
sbColorG.progress,
sbColorB.progress
)
private fun setEditTextListener() {
etColor.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
updateValue(handleUnknownColor(s.toString()))
}
override fun afterTextChanged(s: Editable) {}
})
}
private fun setSeekBarListener() {
val listener: OnSeekBarChangeListener = object : OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
if (fromUser) {
val color = Color.argb(
sbColorA.progress,
sbColorR.progress,
sbColorG.progress,
sbColorB.progress
)
etColor.setText(String.format("%08X", 0xFFFFFFFF.toInt() and color))
}
tvColorA.text = sbColorA.progress.toString()
tvColorR.text = sbColorR.progress.toString()
tvColorG.text = sbColorG.progress.toString()
tvColorB.text = sbColorB.progress.toString()
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
}
sbColorA.setOnSeekBarChangeListener(listener)
sbColorR.setOnSeekBarChangeListener(listener)
sbColorG.setOnSeekBarChangeListener(listener)
sbColorB.setOnSeekBarChangeListener(listener)
}
private fun updateValue(color: Int) {
sampleView.setBackgroundColor(color)
val progressA = Color.alpha(color)
val progressR = Color.red(color)
val progressG = Color.green(color)
val progressB = Color.blue(color)
sbColorA.progress = progressA
sbColorR.progress = progressR
sbColorG.progress = progressG
sbColorB.progress = progressB
tvColorA.text = progressA.toString()
tvColorR.text = progressR.toString()
tvColorG.text = progressG.toString()
tvColorB.text = progressB.toString()
}
private fun handleUnknownColor(color: String) =
try {
Color.parseColor("#$color")
} catch (e: IllegalArgumentException) {
Color.BLACK
}
init {
setView(view)
setEditTextListener()
setSeekBarListener()
updateValue(defColor)
etColor.setText(String.format("%08X", 0xFFFFFFFF.toInt() and defColor))
setTitle("拾色器")
setNegativeButton("取消", null)
}
}
@@ -0,0 +1,264 @@
package me.iacn.biliroaming
import android.app.AlertDialog
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.text.TextUtils
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.*
import me.iacn.biliroaming.utils.children
import me.iacn.biliroaming.utils.dp
import me.iacn.biliroaming.utils.setRippleBackground
open class BaseWidgetDialog(context: Context) : AlertDialog.Builder(context) {
protected fun string(resId: Int) = XposedInit.moduleRes.getString(resId)
protected fun categoryTitle(title: String) = TextView(context).apply {
text = title
typeface = Typeface.DEFAULT_BOLD
setTextSize(TypedValue.COMPLEX_UNIT_SP, 18F)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { topMargin = 10.dp }
}
protected fun textInputTitle(title: String) = TextView(context).apply {
text = title
setTextSize(TypedValue.COMPLEX_UNIT_SP, 18F)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
protected fun textInputItem(
name: String,
type: Int = EditorInfo.TYPE_CLASS_NUMBER
): Pair<LinearLayout, EditText> {
val layout = LinearLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
val textView = TextView(context).apply {
text = name
setSingleLine()
ellipsize = TextUtils.TruncateAt.END
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
val editText = EditText(context).apply {
setSingleLine()
inputType = type
layoutParams = LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { weight = 1F }
}
layout.addView(textView)
layout.addView(editText)
return Pair(layout, editText)
}
private fun keywordTypeHeader(
group: ViewGroup,
name: String,
namMinWidth: Int,
showRegex: Boolean,
onAdd: (v: View) -> Unit,
): Triple<LinearLayout, Button, Switch> {
val layout = LinearLayout(context).apply {
gravity = Gravity.START
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
val nameView = TextView(context).apply {
text = name
typeface = Typeface.DEFAULT_BOLD
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16F)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
minWidth = namMinWidth
}
val addView = Button(context).apply {
text = string(R.string.keyword_group_add)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
minWidth = 60.dp
minimumWidth = 60.dp
setOnClickListener(onAdd)
}
val clearView = Button(context).apply {
text = string(R.string.keyword_group_clear)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
visibility = View.GONE
minWidth = 60.dp
minimumWidth = 60.dp
setOnClickListener { group.removeAllViews() }
}
val regexModeView = TextView(context).apply {
text = string(R.string.regex_mode)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { marginStart = 4.dp }
}
val regexModeSwitch = Switch(context).apply {
isSoundEffectsEnabled = false
isHapticFeedbackEnabled = false
}
if (!showRegex) {
regexModeView.visibility = View.GONE
regexModeSwitch.visibility = View.GONE
}
layout.addView(nameView)
layout.addView(addView)
layout.addView(clearView)
layout.addView(regexModeView)
layout.addView(regexModeSwitch)
return Triple(layout, clearView, regexModeSwitch)
}
protected fun keywordInputItem(
parent: ViewGroup,
keyword: String = "",
type: Int = EditorInfo.TYPE_CLASS_TEXT,
): Pair<LinearLayout, EditText> {
val layout = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
val editText = EditText(context).apply {
inputType = type
setText(keyword)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16F)
setSingleLine()
layoutParams = LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { weight = 1F }
}
val button = Button(context).apply {
text = string(R.string.keyword_group_delete)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
minWidth = 60.dp
minimumWidth = 60.dp
setOnClickListener { parent.removeView(layout) }
}
layout.addView(editText)
layout.addView(button)
return Pair(layout, editText)
}
protected fun LinearLayout.addKeywordGroup(
name: String,
namMinWidth: Int = 64.dp,
showRegex: Boolean = false,
inputType: Int = EditorInfo.TYPE_CLASS_TEXT,
): Pair<ViewGroup, Switch> {
val group = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
val (_, clearButton, regexModeSwitch) = keywordTypeHeader(
group, name, namMinWidth, showRegex
) {
keywordInputItem(group, type = inputType).let {
group.addView(it.first)
it.second.requestFocus()
}
}.also { addView(it.first) }
group.setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
override fun onChildViewAdded(parent: View, child: View) {
if (group.childCount == 0) {
clearButton.visibility = View.GONE
} else {
clearButton.visibility = View.VISIBLE
}
}
override fun onChildViewRemoved(parent: View, child: View) {
if (group.childCount == 0) {
clearButton.visibility = View.GONE
} else {
clearButton.visibility = View.VISIBLE
}
}
})
addView(group)
return Pair(group, regexModeSwitch)
}
protected fun switchPrefsItem(title: String): Pair<LinearLayout, Switch> {
val layout = LinearLayout(context).apply {
gravity = Gravity.CENTER_VERTICAL
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
val titleView = TextView(context).apply {
text = title
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16F)
setSingleLine()
ellipsize = TextUtils.TruncateAt.END
setPadding(0, 8.dp, 0, 8.dp)
layoutParams = LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { weight = 1F; marginEnd = 10.dp }
}
val switcher = Switch(context).apply {
isClickable = false
isSoundEffectsEnabled = false
isHapticFeedbackEnabled = false
setBackgroundColor(Color.TRANSPARENT)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
layout.setOnClickListener { switcher.toggle() }
layout.setRippleBackground()
layout.addView(titleView)
layout.addView(switcher)
return Pair(layout, switcher)
}
protected fun ViewGroup.getKeywords() = children
.filterIsInstance<ViewGroup>()
.flatMap { it.children }
.filterIsInstance<EditText>()
.map { it.text.toString().trim() }
.filter { it.isNotEmpty() }
.toSet()
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,92 @@
package me.iacn.biliroaming
import android.app.AlertDialog
import android.content.Context
import android.graphics.Color
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.widget.EditText
import android.widget.SeekBar
import android.widget.SeekBar.OnSeekBarChangeListener
import android.widget.TextView
import me.iacn.biliroaming.utils.inflateLayout
/**
* Created by iAcn on 2019/7/14
* Email i@iacn.me
*/
class ColorChooseDialog(context: Context, defColor: Int) : AlertDialog.Builder(context) {
private val view = context.inflateLayout(R.layout.dialog_color_choose)
private val sampleView: View = view.findViewById(R.id.view_sample)
private val etColor: EditText = view.findViewById(R.id.et_color)
private val sbColorR: SeekBar = view.findViewById(R.id.sb_colorR)
private val sbColorG: SeekBar = view.findViewById(R.id.sb_colorG)
private val sbColorB: SeekBar = view.findViewById(R.id.sb_colorB)
private val tvColorR: TextView = view.findViewById(R.id.tv_colorR)
private val tvColorG: TextView = view.findViewById(R.id.tv_colorG)
private val tvColorB: TextView = view.findViewById(R.id.tv_colorB)
val color: Int
get() = Color.rgb(sbColorR.progress, sbColorG.progress, sbColorB.progress)
private fun setEditTextListener() {
etColor.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
updateValue(handleUnknownColor(s.toString()))
}
override fun afterTextChanged(s: Editable) {}
})
}
private fun setSeekBarListener() {
val listener: OnSeekBarChangeListener = object : OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
if (fromUser) {
val color = Color.rgb(sbColorR.progress, sbColorG.progress, sbColorB.progress)
etColor.setText(String.format("%06X", 0xFFFFFF and color))
}
tvColorR.text = sbColorR.progress.toString()
tvColorG.text = sbColorG.progress.toString()
tvColorB.text = sbColorB.progress.toString()
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
}
sbColorR.setOnSeekBarChangeListener(listener)
sbColorG.setOnSeekBarChangeListener(listener)
sbColorB.setOnSeekBarChangeListener(listener)
}
private fun updateValue(color: Int) {
sampleView.setBackgroundColor(color)
val progressR = Color.red(color)
val progressG = Color.green(color)
val progressB = Color.blue(color)
sbColorR.progress = progressR
sbColorG.progress = progressG
sbColorB.progress = progressB
tvColorR.text = progressR.toString()
tvColorG.text = progressG.toString()
tvColorB.text = progressB.toString()
}
private fun handleUnknownColor(color: String) =
try {
Color.parseColor("#$color")
} catch (e: IllegalArgumentException) {
Color.BLACK
}
init {
setView(view)
setEditTextListener()
setSeekBarListener()
updateValue(defColor)
etColor.setText(String.format("%06X", 0xFFFFFF and defColor))
setTitle("自选颜色")
setNegativeButton("取消", null)
}
}
@@ -0,0 +1,111 @@
package me.iacn.biliroaming
import android.app.Activity
import android.content.SharedPreferences
import android.view.inputmethod.EditorInfo
import android.widget.*
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.dp
import me.iacn.biliroaming.utils.inflateLayout
class CommentFilterDialog(activity: Activity, prefs: SharedPreferences) :
BaseWidgetDialog(activity) {
init {
val scrollView = ScrollView(context).apply {
scrollBarStyle = ScrollView.SCROLLBARS_OUTSIDE_OVERLAY
}
val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
}
scrollView.addView(root)
val (contentGroup, contentRegexSwitch) = root.addKeywordGroup(
string(R.string.keyword_group_name_content), 40.dp, true
)
contentRegexSwitch.isChecked = prefs.getBoolean("comment_filter_content_regex_mode", false)
val upNameGroup = root.addKeywordGroup(string(R.string.keyword_group_name_at_up), 40.dp).first
val uidGroup = root.addKeywordGroup(
string(R.string.keyword_group_name_at_uid),
40.dp,
inputType = EditorInfo.TYPE_CLASS_NUMBER
).first
prefs.getStringSet("comment_filter_keyword_content", null)?.forEach {
contentGroup.addView(keywordInputItem(contentGroup, it).first)
}
prefs.getStringSet("comment_filter_keyword_at_upname", null)?.forEach {
upNameGroup.addView(keywordInputItem(upNameGroup, it).first)
}
prefs.getStringSet("comment_filter_keyword_at_uid", null)?.forEach {
uidGroup.addView(keywordInputItem(uidGroup, it, EditorInfo.TYPE_CLASS_NUMBER).first)
}
val blockAtCommentSwitch = switchPrefsItem(
string(R.string.comment_filter_block_at_comment_title)
).let { root.addView(it.first); it.second }
blockAtCommentSwitch.isChecked = prefs.getBoolean("comment_filter_block_at_comment", false)
val targetCommentAuthorLevelTitle =
categoryTitle(string(R.string.target_comment_author_level_title))
root.addView(targetCommentAuthorLevelTitle)
val seekBarView = context.inflateLayout(R.layout.seekbar_dialog)
val currentTargetCommentAuthorLevel =
prefs.getLong("target_comment_author_level", 0L).toInt()
val tvHint = seekBarView.findViewById<TextView>(R.id.tvHint).apply {
text = if (currentTargetCommentAuthorLevel == 0) "关闭" else XposedInit.moduleRes.getString(
R.string.danmaku_filter_weight_hint,
currentTargetCommentAuthorLevel
)
}
val seekBar = seekBarView.findViewById<SeekBar>(R.id.seekBar).apply {
max = 6
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(
seekBar: SeekBar?, progress: Int, fromUser: Boolean
) {
tvHint.text =
if (progress == 0) "关闭" else XposedInit.moduleRes.getString(
R.string.danmaku_filter_weight_hint,
progress
)
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
progress = currentTargetCommentAuthorLevel
}
root.addView(seekBarView)
setTitle(string(R.string.filter_comment_title))
setPositiveButton(android.R.string.ok) { _, _ ->
val contents = contentGroup.getKeywords()
val contentRegexMode = contentRegexSwitch.isChecked
if (contentRegexMode && contents.runCatching { forEach { it.toRegex() } }.isFailure) {
Log.toast(string(R.string.invalid_regex), force = true)
return@setPositiveButton
}
prefs.edit().apply {
putStringSet("comment_filter_keyword_content", contents)
putStringSet("comment_filter_keyword_at_upname", upNameGroup.getKeywords())
putStringSet("comment_filter_keyword_at_uid", uidGroup.getKeywords())
putBoolean("comment_filter_content_regex_mode", contentRegexMode)
putBoolean("comment_filter_block_at_comment", blockAtCommentSwitch.isChecked)
putLong("target_comment_author_level", seekBar.progress.toLong())
}.apply()
Log.toast(string(R.string.prefs_save_success_and_reboot))
}
setNegativeButton(android.R.string.cancel, null)
root.setPadding(16.dp, 10.dp, 16.dp, 10.dp)
setView(scrollView)
}
}
@@ -0,0 +1,27 @@
package me.iacn.biliroaming
/**
* Created by iAcn on 2019/4/12
* Email i@iacn.me
*/
object Constant {
const val PINK_PACKAGE_NAME = "tv.danmaku.bili"
const val BLUE_PACKAGE_NAME = "com.bilibili.app.blue"
const val PLAY_PACKAGE_NAME = "com.bilibili.app.in"
const val HD_PACKAGE_NAME = "tv.danmaku.bilibilihd"
val BILIBILI_PACKAGE_NAME = hashMapOf(
"原版" to PINK_PACKAGE_NAME,
"概念版" to BLUE_PACKAGE_NAME,
"play版" to PLAY_PACKAGE_NAME,
"HD版" to HD_PACKAGE_NAME
)
const val TAG = "BiliRoaming"
const val HOOK_INFO_FILE_NAME = "hookinfo.pb"
const val TYPE_SEASON_ID = 0
const val TYPE_MEDIA_ID = 1
const val TYPE_EPISODE_ID = 2
const val CUSTOM_COLOR_KEY = "biliroaming_custom_color"
const val CURRENT_COLOR_KEY = "theme_entries_current_key"
const val DEFAULT_CUSTOM_COLOR = -0xe6b7d
const val zoneUrl = "https://api.bilibili.com/x/web-interface/zone"
}
@@ -0,0 +1,192 @@
@file:Suppress("DEPRECATION")
package me.iacn.biliroaming
import android.app.Activity
import android.app.AlertDialog
import android.app.Fragment
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.Color
import android.text.SpannableString
import android.text.Spanned
import android.view.View
import android.widget.*
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.hook.SubtitleHook
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.inflateLayout
class CustomSubtitleDialog(activity: Activity, fragment: Fragment, prefs: SharedPreferences) :
AlertDialog.Builder(activity) {
private var fontStatus: TextView? = null
init {
val oldClient = instance.cronCanvasClass == null
val view = context.inflateLayout(R.layout.custom_subtitle_dialog)
val noBgSwitch = view.findViewById<Switch>(R.id.noBg).apply {
isSoundEffectsEnabled = false
isHapticFeedbackEnabled = false
isChecked = prefs.getBoolean(tag.toString(), true)
}
val llNoBg = view.findViewById<View>(R.id.ll_noBg)
llNoBg.setOnClickListener { noBgSwitch.toggle() }
val boldSwitch = view.findViewById<Switch>(R.id.bold).apply {
isSoundEffectsEnabled = false
isHapticFeedbackEnabled = false
isChecked = prefs.getBoolean(tag.toString(), true)
}
val llBold = view.findViewById<View>(R.id.ll_bold)
llBold.setOnClickListener { boldSwitch.toggle() }
fontStatus = view.findViewById(R.id.tv_fontStatus)
refreshFontStatus()
val fontColor = view.findViewById<EditText>(R.id.font_color)
fontColor.setText(prefs.getString(fontColor.tag.toString(), "FFFFFFFF"))
val backgroundColor = view.findViewById<EditText>(R.id.background_color)
backgroundColor.setText(prefs.getString(backgroundColor.tag.toString(), "20000000"))
val fontSize = view.findViewById<EditText>(R.id.font_size)
fontSize.setText(prefs.getInt(fontSize.tag.toString(), 30).toString())
val fontSizePortrait = view.findViewById<EditText>(R.id.fontSizePortrait)
fontSizePortrait.setText(prefs.getInt(fontSizePortrait.tag.toString(), 0).toString())
val fontSizeLandscape = view.findViewById<EditText>(R.id.fontSizeLandscape)
fontSizeLandscape.setText(prefs.getInt(fontSizeLandscape.tag.toString(), 0).toString())
val fontBlurSolid = view.findViewById<EditText>(R.id.font_blur_solid)
fontBlurSolid.setText(prefs.getInt(fontBlurSolid.tag.toString(), 1).toString())
val strokeColor = view.findViewById<EditText>(R.id.stroke_color)
strokeColor.setText(prefs.getString(strokeColor.tag.toString(), "FF000000"))
val strokeWidth = view.findViewById<EditText>(R.id.stroke_width)
strokeWidth.setText(prefs.getFloat(strokeWidth.tag.toString(), 5.0F).toString())
val fixBreak = view.findViewById<CheckBox>(R.id.cb_fixBreak)
fixBreak.isChecked = prefs.getBoolean(fixBreak.tag.toString(), false)
val offset = view.findViewById<EditText>(R.id.subOffset)
offset.setText(prefs.getInt(offset.tag.toString(), 0).toString())
view.findViewById<Button>(R.id.btn_pv).setOnClickListener {
val testText = view.findViewById<EditText>(R.id.et_testText).text.toString()
val spannableString = SpannableString(testText)
val fc = fontColor.text.toString()
val bc = backgroundColor.text.toString()
val fs = fontSize.text.toString().toInt()
val fbs = fontBlurSolid.text.toString().toInt()
val sc = strokeColor.text.toString()
val sw = strokeWidth.text.toString().toFloat()
SubtitleHook.subtitleStylizeRunner(
spannableString,
0,
spannableString.length,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE,
fbs, fc, fs, bc, sc, sw,
fixBreak.isChecked
)
view.findViewById<TextView>(R.id.tv_pvBlack).text = spannableString
view.findViewById<TextView>(R.id.tv_pvWhite).text = spannableString
view.findViewById<TextView>(R.id.tv_pvTp).text = spannableString
}
view.findViewById<Button>(R.id.btn_chooseColorBc).setOnClickListener {
ARGBColorChooseDialog(context, Color.parseColor("#${backgroundColor.text}")).apply {
setPositiveButton(android.R.string.ok) { _, _ ->
backgroundColor.setText(String.format("%08X", 0xFFFFFFFF.toInt() and color))
}
}.show()
}
view.findViewById<Button>(R.id.btn_chooseColorFc).setOnClickListener {
ARGBColorChooseDialog(context, Color.parseColor("#${fontColor.text}")).apply {
setPositiveButton(android.R.string.ok) { _, _ ->
fontColor.setText(String.format("%08X", 0xFFFFFFFF.toInt() and color))
}
}.show()
}
view.findViewById<Button>(R.id.btn_chooseColorSc).setOnClickListener {
ARGBColorChooseDialog(context, Color.parseColor("#${strokeColor.text}")).apply {
setPositiveButton(android.R.string.ok) { _, _ ->
strokeColor.setText(String.format("%08X", 0xFFFFFFFF.toInt() and color))
}
}.show()
}
view.findViewById<Button>(R.id.btn_importFont).setOnClickListener {
try {
fragment.startActivityForResult(
Intent.createChooser(Intent(Intent.ACTION_GET_CONTENT).apply {
type = "font/*"
addCategory(Intent.CATEGORY_OPENABLE)
}, "选择字体文件"),
2338
)
} catch (ex: ActivityNotFoundException) {
Log.toast("请安装文件管理器")
}
}
view.findViewById<Button>(R.id.btn_resetFont).setOnClickListener {
SubtitleHook.fontFile.delete()
refreshFontStatus()
}
if (oldClient) {
llNoBg.visibility = View.GONE
llBold.visibility = View.GONE
view.findViewById<View>(R.id.ll_font).visibility = View.GONE
view.findViewById<View>(R.id.ll_sizePortrait).visibility = View.GONE
view.findViewById<View>(R.id.ll_sizeLandscape).visibility = View.GONE
view.findViewById<View>(R.id.ll_subOffset).visibility = View.GONE
} else {
view.findViewById<View>(R.id.pvBlack).visibility = View.GONE
view.findViewById<View>(R.id.pvWhite).visibility = View.GONE
view.findViewById<View>(R.id.pvTp).visibility = View.GONE
view.findViewById<View>(R.id.ll_bg).visibility = View.GONE
view.findViewById<View>(R.id.ll_size).visibility = View.GONE
view.findViewById<View>(R.id.ll_blur).visibility = View.GONE
view.findViewById<View>(R.id.ll_pv).visibility = View.GONE
fixBreak.visibility = View.GONE
}
setPositiveButton(android.R.string.ok) { _, _ ->
prefs.edit().apply {
putBoolean(noBgSwitch.tag.toString(), noBgSwitch.isChecked)
putBoolean(boldSwitch.tag.toString(), boldSwitch.isChecked)
putString(fontColor.tag.toString(), fontColor.text.toString())
putString(backgroundColor.tag.toString(), backgroundColor.text.toString())
putInt(fontSize.tag.toString(), fontSize.text.toString().toIntOrNull() ?: 30)
putInt(
fontSizePortrait.tag.toString(),
fontSizePortrait.text.toString().toIntOrNull() ?: 0
)
putInt(
fontSizeLandscape.tag.toString(),
fontSizeLandscape.text.toString().toIntOrNull() ?: 0
)
putInt(
fontBlurSolid.tag.toString(),
fontBlurSolid.text.toString().toIntOrNull() ?: 1
)
putString(strokeColor.tag.toString(), strokeColor.text.toString())
putFloat(
strokeWidth.tag.toString(),
strokeWidth.text.toString().toFloatOrNull() ?: 5.0F
)
putBoolean(fixBreak.tag.toString(), fixBreak.isChecked)
putInt(offset.tag.toString(), offset.text.toString().toIntOrNull() ?: 0)
}.apply()
}
setTitle(activity.getString(R.string.custom_subtitle_title))
setView(view)
}
private fun refreshFontStatus() {
fontStatus?.text = if (SubtitleHook.fontFile.isFile)
XposedInit.moduleRes.getString(R.string.custom_subtitle_status_custom)
else
XposedInit.moduleRes.getString(R.string.custom_subtitle_status_default)
}
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val uri = data?.data
if (requestCode != 2338 || resultCode != Activity.RESULT_OK || uri == null) return
context.contentResolver.openInputStream(uri)?.use {
val fontFile = SubtitleHook.fontFile.apply { delete() }
it.copyTo(fontFile.outputStream())
refreshFontStatus()
}
}
}
@@ -0,0 +1,170 @@
package me.iacn.biliroaming
import android.app.Activity
import android.content.SharedPreferences
import android.view.inputmethod.EditorInfo
import android.widget.*
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.dp
class DynamicFilterDialog(activity: Activity, prefs: SharedPreferences) :
BaseWidgetDialog(activity) {
init {
val scrollView = ScrollView(context).apply {
scrollBarStyle = ScrollView.SCROLLBARS_OUTSIDE_OVERLAY
}
val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
}
scrollView.addView(root)
val preferVideoTabSwitch = switchPrefsItem(
string(R.string.customize_dynamic_prefer_video_tab)
).let { root.addView(it.first); it.second }
preferVideoTabSwitch.isChecked = prefs.getBoolean("prefer_video_tab", false)
val rmCityTabSwitch = switchPrefsItem(string(R.string.purify_city_title))
.let { root.addView(it.first); it.second }
rmCityTabSwitch.isChecked = prefs.getBoolean("purify_city", false)
val rmCampusTabSwitch = switchPrefsItem(string(R.string.purify_campus_title))
.let { root.addView(it.first); it.second }
rmCampusTabSwitch.isChecked = prefs.getBoolean("purify_campus", false)
val rmTopicOfAllSwitch = switchPrefsItem(
string(R.string.customize_dynamic_all_rm_topic_title)
).let { root.addView(it.first); it.second }
rmTopicOfAllSwitch.isChecked = prefs.getBoolean("customize_dynamic_all_rm_topic", false)
val rmUpOfAllSwitch = switchPrefsItem(
string(R.string.customize_dynamic_all_rm_up_title)
).let { root.addView(it.first); it.second }
rmUpOfAllSwitch.isChecked = prefs.getBoolean("customize_dynamic_all_rm_up", false)
val rmLiveOfAllSwitch = switchPrefsItem(
string(R.string.customize_dynamic_all_rm_live_title)
).let { root.addView(it.first); it.second }
rmLiveOfAllSwitch.isChecked = prefs.getBoolean("customize_dynamic_all_rm_live", false)
val rmUpOfVideoSwitch = switchPrefsItem(
string(R.string.customize_dynamic_video_rm_up_title)
).let { root.addView(it.first); it.second }
rmUpOfVideoSwitch.isChecked = prefs.getBoolean("customize_dynamic_video_rm_up", false)
val filterApplyToVideoSwitch = switchPrefsItem(
string(R.string.customize_dynamic_filter_apply_to_video)
).let { root.addView(it.first); it.second }
filterApplyToVideoSwitch.isChecked = prefs.getBoolean("filter_apply_to_video", false)
val rmBlockedSwitch = switchPrefsItem(
string(R.string.customize_dynamic_rm_blocked_title)
).let { root.addView(it.first); it.second }
rmBlockedSwitch.isChecked = prefs.getBoolean("customize_dynamic_rm_blocked", false)
val rmAdLinkSwitch = switchPrefsItem(
string(R.string.customize_dynamic_rm_ad_link_title)
).let { root.addView(it.first); it.second }
rmAdLinkSwitch.isChecked = prefs.getBoolean("customize_dynamic_rm_ad_link", false)
val byTypeTitle = categoryTitle(string(R.string.customize_dynamic_by_type))
root.addView(byTypeTitle)
val gridLayout = GridLayout(context).apply {
columnCount = 4
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
root.addView(gridLayout)
val dynamicTypes = XposedInit.moduleRes.getStringArray(R.array.dynamic_entries).zip(
XposedInit.moduleRes.getStringArray(R.array.dynamic_values)
)
val colSpec = fun(colWeight: Float) = GridLayout.spec(GridLayout.UNDEFINED, colWeight)
val rowSpec = { GridLayout.spec(GridLayout.UNDEFINED) }
val typePrefs = prefs.getStringSet("customize_dynamic_type", null) ?: setOf()
dynamicTypes.forEach { item ->
val checkBox = CheckBox(context).apply {
tag = item.second
layoutParams = GridLayout.LayoutParams(rowSpec(), colSpec(1F))
isChecked = typePrefs.contains(item.second)
}
val textView = TextView(context).apply {
text = item.first
layoutParams = GridLayout.LayoutParams(rowSpec(), colSpec(2F))
setSingleLine()
}
gridLayout.addView(checkBox)
gridLayout.addView(textView)
}
val byKeywordTitle = categoryTitle(string(R.string.customize_dynamic_by_keyword))
root.addView(byKeywordTitle)
val (contentGroup, contentRegexSwitch) = root.addKeywordGroup(
string(R.string.keyword_group_name_content), 40.dp, true
)
contentRegexSwitch.isChecked = prefs.getBoolean("dynamic_content_regex_mode", false)
val upNameGroup = root.addKeywordGroup(string(R.string.keyword_group_name_up), 40.dp).first
val uidGroup = root.addKeywordGroup(
string(R.string.keyword_group_name_uid),
40.dp,
inputType = EditorInfo.TYPE_CLASS_NUMBER
).first
prefs.getStringSet("customize_dynamic_keyword_content", null)?.forEach {
contentGroup.addView(keywordInputItem(contentGroup, it).first)
}
prefs.getStringSet("customize_dynamic_keyword_upname", null)?.forEach {
upNameGroup.addView(keywordInputItem(upNameGroup, it).first)
}
prefs.getStringSet("customize_dynamic_keyword_uid", null)?.forEach {
uidGroup.addView(keywordInputItem(uidGroup, it, EditorInfo.TYPE_CLASS_NUMBER).first)
}
setTitle(string(R.string.customize_dynamic_title))
setPositiveButton(android.R.string.ok) { _, _ ->
val typeValues = buildSet {
for (i in 0 until gridLayout.childCount step 2) {
val view = gridLayout.getChildAt(i) as CheckBox
if (view.isChecked) add(view.tag.toString())
}
}
val contents = contentGroup.getKeywords()
val contentRegexMode = contentRegexSwitch.isChecked
if (contentRegexMode && contents.runCatching { forEach { it.toRegex() } }.isFailure) {
Log.toast(string(R.string.invalid_regex), force = true)
return@setPositiveButton
}
prefs.edit().apply {
putBoolean("prefer_video_tab", preferVideoTabSwitch.isChecked)
putBoolean("purify_city", rmCityTabSwitch.isChecked)
putBoolean("purify_campus", rmCampusTabSwitch.isChecked)
putBoolean("customize_dynamic_all_rm_topic", rmTopicOfAllSwitch.isChecked)
putBoolean("customize_dynamic_all_rm_up", rmUpOfAllSwitch.isChecked)
putBoolean("customize_dynamic_all_rm_live", rmLiveOfAllSwitch.isChecked)
putBoolean("customize_dynamic_video_rm_up", rmUpOfVideoSwitch.isChecked)
putBoolean("filter_apply_to_video", filterApplyToVideoSwitch.isChecked)
putBoolean("customize_dynamic_rm_blocked", rmBlockedSwitch.isChecked)
putBoolean("customize_dynamic_rm_ad_link", rmAdLinkSwitch.isChecked)
putStringSet("customize_dynamic_type", typeValues)
putStringSet("customize_dynamic_keyword_content", contents)
putStringSet("customize_dynamic_keyword_upname", upNameGroup.getKeywords())
putStringSet("customize_dynamic_keyword_uid", uidGroup.getKeywords())
putBoolean("dynamic_content_regex_mode", contentRegexMode)
}.apply()
Log.toast(string(R.string.prefs_save_success_and_reboot))
}
setNegativeButton(android.R.string.cancel, null)
root.setPadding(16.dp, 10.dp, 16.dp, 10.dp)
setView(scrollView)
}
}
@@ -0,0 +1,152 @@
package me.iacn.biliroaming
import android.app.Activity
import android.content.SharedPreferences
import android.view.inputmethod.EditorInfo
import android.widget.*
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.dp
import me.iacn.biliroaming.utils.migrateHomeFilterPrefsIfNeeded
class HomeFilterDialog(activity: Activity, prefs: SharedPreferences) :
BaseWidgetDialog(activity) {
init {
migrateHomeFilterPrefsIfNeeded()
val scrollView = ScrollView(context).apply {
scrollBarStyle = ScrollView.SCROLLBARS_OUTSIDE_OVERLAY
}
val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
}
scrollView.addView(root)
val hideTopSwitch = switchPrefsItem((string(R.string.hide_top_entrance_popular_summary)))
.let { root.addView(it.first); it.second }
hideTopSwitch.isChecked = prefs.getBoolean("hide_top_entrance_popular", false)
val hideFollowSwitch = switchPrefsItem((string(R.string.hide_suggest_follow_popular_summary)))
.let { root.addView(it.first); it.second }
hideFollowSwitch.isChecked = prefs.getBoolean("hide_suggest_follow_popular", false)
val hideTopicListSwitch = switchPrefsItem(string(R.string.hide_topic_list_popular_summary))
.let { root.addView(it.first); it.second }
hideTopicListSwitch.isChecked = prefs.getBoolean("hide_topic_list_popular", false)
val applyToRelateSwitch = switchPrefsItem(string(R.string.apply_to_relate_title))
.let { root.addView(it.first); it.second }
applyToRelateSwitch.isChecked = prefs.getBoolean("home_filter_apply_to_relate", false)
root.addView(textInputTitle(string(R.string.hide_low_play_count_recommend_summary)))
val lowPlayCountInput = textInputItem(string(R.string.hide_low_play_count_recommend_title))
.let { root.addView(it.first); it.second }
prefs.getLong("hide_low_play_count_recommend_limit", 0).takeIf { it > 0 }
?.let { lowPlayCountInput.setText(it.toString()) }
root.addView(textInputTitle(string(R.string.hide_duration_recommend_summary)))
val shortDurationInput = textInputItem(string(R.string.hide_short_duration_recommend_title))
.let { root.addView(it.first); it.second }
val longDurationInput = textInputItem(string(R.string.hide_long_duration_recommend_title))
.let { root.addView(it.first); it.second }
prefs.getInt("hide_short_duration_recommend_limit", 0).takeIf { it > 0 }
?.let { shortDurationInput.setText(it.toString()) }
prefs.getInt("hide_long_duration_recommend_limit", 0).takeIf { it > 0 }
?.let { longDurationInput.setText(it.toString()) }
root.addView(textInputTitle(string(R.string.keywords_filter_recommend_summary)))
val (titleGroup, titleRegexModeSwitch) = root.addKeywordGroup(
string(R.string.keyword_group_name_title), showRegex = true
)
titleRegexModeSwitch.isChecked = prefs.getBoolean("home_filter_title_regex_mode", false)
val (reasonGroup, reasonRegexModeSwitch) = root.addKeywordGroup(
string(R.string.keyword_group_name_rcmd_reason), showRegex = true
)
reasonRegexModeSwitch.isChecked = prefs.getBoolean("home_filter_reason_regex_mode", false)
val uidGroup = root.addKeywordGroup(
string(R.string.keyword_group_name_uid),
inputType = EditorInfo.TYPE_CLASS_NUMBER
).first
val (upGroup, upRegexModeSwitch) = root.addKeywordGroup(
string(R.string.keyword_group_name_up), showRegex = true
)
upRegexModeSwitch.isChecked = prefs.getBoolean("home_filter_up_regex_mode", false)
val categoryGroup = root.addKeywordGroup(string(R.string.keyword_group_name_category)).first
val channelGroup = root.addKeywordGroup(string(R.string.keyword_group_name_channel)).first
prefs.getStringSet("home_filter_keywords_title", null)?.forEach {
titleGroup.addView(keywordInputItem(titleGroup, it).first)
}
prefs.getStringSet("home_filter_keywords_reason", null)?.forEach {
reasonGroup.addView(keywordInputItem(reasonGroup, it).first)
}
prefs.getStringSet("home_filter_keywords_uid", null)?.forEach {
uidGroup.addView(keywordInputItem(uidGroup, it, EditorInfo.TYPE_CLASS_NUMBER).first)
}
prefs.getStringSet("home_filter_keywords_up", null)?.forEach {
upGroup.addView(keywordInputItem(upGroup, it).first)
}
prefs.getStringSet("home_filter_keywords_category", null)?.forEach {
categoryGroup.addView(keywordInputItem(categoryGroup, it).first)
}
prefs.getStringSet("home_filter_keywords_channel", null)?.forEach {
channelGroup.addView(keywordInputItem(channelGroup, it).first)
}
setTitle(string(R.string.home_filter_title))
setPositiveButton(android.R.string.ok) { _, _ ->
val hideTop = hideTopSwitch.isChecked
val hideSuggestFollow = hideFollowSwitch.isChecked
val hideTopicList = hideTopicListSwitch.isChecked
val lowPlayCount = lowPlayCountInput.text.toString().toLongOrNull() ?: 0
val shortDuration = shortDurationInput.text.toString().toIntOrNull() ?: 0
val longDuration = longDurationInput.text.toString().toIntOrNull() ?: 0
val titles = titleGroup.getKeywords()
val titleRegexMode = titleRegexModeSwitch.isChecked
if (titleRegexMode && titles.runCatching { forEach { it.toRegex() } }.isFailure) {
Log.toast(string(R.string.invalid_regex), force = true)
return@setPositiveButton
}
val reasons = reasonGroup.getKeywords()
val reasonRegexMode = reasonRegexModeSwitch.isChecked
if (reasonRegexMode && reasons.runCatching { forEach { it.toRegex() } }.isFailure) {
Log.toast(string(R.string.invalid_regex), force = true)
return@setPositiveButton
}
val ups = upGroup.getKeywords()
val upRegexMode = upRegexModeSwitch.isChecked
if (upRegexMode && ups.runCatching { forEach { it.toRegex() } }.isFailure) {
Log.toast(string(R.string.invalid_regex), force = true)
return@setPositiveButton
}
prefs.edit().apply {
putBoolean("hide_top_entrance_popular", hideTop)
putBoolean("hide_topic_list_popular", hideTopicList)
putBoolean("hide_suggest_follow_popular", hideSuggestFollow)
putLong("hide_low_play_count_recommend_limit", lowPlayCount)
putInt("hide_short_duration_recommend_limit", shortDuration)
putInt("hide_long_duration_recommend_limit", longDuration)
putStringSet("home_filter_keywords_title", titles)
putStringSet("home_filter_keywords_reason", reasons)
putStringSet("home_filter_keywords_uid", uidGroup.getKeywords())
putStringSet("home_filter_keywords_up", ups)
putStringSet("home_filter_keywords_category", categoryGroup.getKeywords())
putStringSet("home_filter_keywords_channel", channelGroup.getKeywords())
putBoolean("home_filter_title_regex_mode", titleRegexMode)
putBoolean("home_filter_reason_regex_mode", reasonRegexMode)
putBoolean("home_filter_up_regex_mode", upRegexMode)
putBoolean("home_filter_apply_to_relate", applyToRelateSwitch.isChecked)
}.apply()
Log.toast(string(R.string.prefs_save_success_and_reboot))
}
setNegativeButton(android.R.string.cancel, null)
root.setPadding(16.dp, 10.dp, 16.dp, 10.dp)
setView(scrollView)
}
}
@@ -0,0 +1,205 @@
@file:Suppress("DEPRECATION")
package me.iacn.biliroaming
import android.app.Activity
import android.app.AlertDialog
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.preference.Preference
import android.preference.Preference.OnPreferenceChangeListener
import android.preference.Preference.OnPreferenceClickListener
import android.preference.PreferenceCategory
import android.preference.PreferenceFragment
import android.util.Log
import android.view.View
import android.widget.Toast
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import me.iacn.biliroaming.hook.SettingHook
import me.iacn.biliroaming.utils.fetchJson
/**
* Created by iAcn on 2019/3/23
* Email i@iacn.me
*/
class MainActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
fragmentManager.beginTransaction().replace(android.R.id.content, PrefsFragment()).commit()
}
class PrefsFragment : PreferenceFragment(), OnPreferenceChangeListener,
OnPreferenceClickListener {
private lateinit var runningStatusPref: Preference
private val scope = MainScope()
@Deprecated("Deprecated in Java")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
addPreferencesFromResource(R.xml.main_activity)
runningStatusPref = findPreference("running_status")
findPreference("hide_icon").onPreferenceChangeListener = this
findPreference("version").summary = BuildConfig.VERSION_NAME
findPreference("feature").onPreferenceClickListener = this
findPreference("setting").onPreferenceClickListener = this
findPreference("clear_hook_cache").onPreferenceClickListener = this
checkUpdate()
}
@Deprecated("Deprecated in Java")
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
@Deprecated("Deprecated in Java")
override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean {
when (preference.key) {
"hide_icon" -> {
val isShow = newValue as Boolean
val aliasName = ComponentName(activity, MainActivity::class.java.name + "Alias")
val packageManager = activity.packageManager
val status =
if (isShow) PackageManager.COMPONENT_ENABLED_STATE_DISABLED else PackageManager.COMPONENT_ENABLED_STATE_ENABLED
if (packageManager.getComponentEnabledSetting(aliasName) != status) {
packageManager.setComponentEnabledSetting(
aliasName,
status,
PackageManager.DONT_KILL_APP
)
}
}
}
return true
}
@Deprecated("Deprecated in Java")
override fun onResume() {
super.onResume()
when {
isModuleActive() -> {
runningStatusPref.setTitle(R.string.running_status_enable)
runningStatusPref.setSummary(R.string.runtime_xposed)
}
isTaiChiModuleActive(activity) -> {
runningStatusPref.setTitle(R.string.running_status_enable)
runningStatusPref.setSummary(R.string.runtime_taichi)
}
else -> {
runningStatusPref.setTitle(R.string.running_status_disable)
runningStatusPref.setSummary(R.string.not_running_summary)
}
}
}
private fun checkUpdate() = scope.launch {
val result = fetchJson(resources.getString(R.string.version_url)) ?: return@launch
val newestVer = result.optString("name")
if (newestVer.isNotEmpty() && BuildConfig.VERSION_NAME != newestVer) {
findPreference("version").summary = "${BuildConfig.VERSION_NAME}(最新版$newestVer)"
(findPreference("about") as PreferenceCategory).addPreference(Preference(activity).apply {
key = "update"
title = resources.getString(R.string.update_title)
summary = result.optString("body").substringAfterLast("更新日志\r\n")
.ifEmpty { resources.getString(R.string.update_summary) }
onPreferenceClickListener = this@PrefsFragment
order = 1
})
}
}
private fun onUpdateCheck(): Boolean {
val uri = Uri.parse(resources.getString(R.string.update_url))
val intent = Intent(Intent.ACTION_VIEW, uri)
startActivity(intent)
return true
}
private fun onFeatureClick(): Boolean {
AlertDialog.Builder(activity).run {
setView(View.inflate(activity, R.layout.feature, null))
setNegativeButton("关闭", null)
show()
}
return true
}
private fun onSettingClick(): Boolean {
val packages = Constant.BILIBILI_PACKAGE_NAME.filter { isPackageInstalled(it.value) }
when {
packages.size == 1 -> startSetting(packages.values.first())
packages.isEmpty() -> Toast.makeText(activity, "未检测到已安装的客户端", Toast.LENGTH_LONG)
.show()
else -> {
AlertDialog.Builder(activity).run {
val keys = packages.keys.toTypedArray()
setItems(keys) { _, i -> startSetting(packages[keys[i]] ?: error("")) }
setTitle("请选择版本")
show()
}
}
}
return true
}
@Deprecated("Deprecated in Java")
override fun onPreferenceClick(preference: Preference?) = when (preference?.key) {
"update" -> onUpdateCheck()
"feature" -> onFeatureClick()
"setting" -> onSettingClick()
"clear_hook_cache" -> onClearHookCacheClick()
else -> false
}
private fun isPackageInstalled(packageName: String) = try {
activity.packageManager.getPackageInfo(packageName, 0)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
private fun startSetting(packageName: String) {
activity.packageManager.getLaunchIntentForPackage(packageName)?.run {
addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
putExtra(SettingHook.START_SETTING_KEY, true)
startActivity(this)
}
}
private fun onClearHookCacheClick(): Boolean {
val prefs = activity.getSharedPreferences("biliroaming_hookinfo", Context.MODE_PRIVATE)
val nextGen = prefs.getInt("generation", 0) + 1
prefs.edit().putInt("generation", nextGen).apply()
Toast.makeText(activity, "将在下次启动哔哩哔哩时重新扫描", Toast.LENGTH_SHORT).show()
return true
}
}
companion object {
fun isModuleActive(): Boolean {
Log.i("大不自多", "海纳江河")
return false
}
private fun isTaiChiModuleActive(context: Context): Boolean {
val contentResolver = context.contentResolver
val uri = Uri.parse("content://me.weishu.exposed.CP/")
return try {
val result = contentResolver.call(uri, "active", null, null)
result?.getBoolean("active", false) ?: false
} catch (e: Exception) {
false
}
}
}
}
@@ -0,0 +1,78 @@
package me.iacn.biliroaming
import android.app.Activity
import android.content.SharedPreferences
import android.view.inputmethod.EditorInfo
import android.widget.*
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.dp
class SearchFilterDialog(activity: Activity, prefs: SharedPreferences) :
BaseWidgetDialog(activity) {
init {
val scrollView = ScrollView(context).apply {
scrollBarStyle = ScrollView.SCROLLBARS_OUTSIDE_OVERLAY
}
val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
}
scrollView.addView(root)
val categoryVideoTitle = categoryTitle(string(R.string.filter_search_video))
root.addView(categoryVideoTitle)
val (contentGroup, contentRegexSwitch) = root.addKeywordGroup(
string(R.string.keyword_group_name_content), 40.dp, true
)
contentRegexSwitch.isChecked = prefs.getBoolean("search_filter_content_regex_mode", false)
val upNameGroup = root.addKeywordGroup(string(R.string.keyword_group_name_up), 40.dp).first
val uidGroup = root.addKeywordGroup(
string(R.string.keyword_group_name_uid),
40.dp,
inputType = EditorInfo.TYPE_CLASS_NUMBER
).first
prefs.getStringSet("search_filter_keyword_content", null)?.forEach {
contentGroup.addView(keywordInputItem(contentGroup, it).first)
}
prefs.getStringSet("search_filter_keyword_upname", null)?.forEach {
upNameGroup.addView(keywordInputItem(upNameGroup, it).first)
}
prefs.getStringSet("search_filter_keyword_uid", null)?.forEach {
uidGroup.addView(keywordInputItem(uidGroup, it, EditorInfo.TYPE_CLASS_NUMBER).first)
}
val removeRelatePromoteSwitch = switchPrefsItem(string(R.string.filter_search_remove_relate_promote))
.let { root.addView(it.first); it.second }
removeRelatePromoteSwitch.isChecked = prefs.getBoolean("search_filter_remove_relate_promote", false)
setTitle(string(R.string.filter_search_title))
setPositiveButton(android.R.string.ok) { _, _ ->
val contents = contentGroup.getKeywords()
val contentRegexMode = contentRegexSwitch.isChecked
if (contentRegexMode && contents.runCatching { forEach { it.toRegex() } }.isFailure) {
Log.toast(string(R.string.invalid_regex), force = true)
return@setPositiveButton
}
prefs.edit().apply {
putStringSet("search_filter_keyword_content", contents)
putStringSet("search_filter_keyword_upname", upNameGroup.getKeywords())
putStringSet("search_filter_keyword_uid", uidGroup.getKeywords())
putBoolean("search_filter_content_regex_mode", contentRegexMode)
putBoolean("search_filter_remove_relate_promote", removeRelatePromoteSwitch.isChecked)
}.apply()
Log.toast(string(R.string.prefs_save_success_and_reboot))
}
setNegativeButton(android.R.string.cancel, null)
root.setPadding(16.dp, 10.dp, 16.dp, 10.dp)
setView(scrollView)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,165 @@
@file:Suppress("DEPRECATION")
package me.iacn.biliroaming
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ListView
import android.widget.TextView
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import me.iacn.biliroaming.network.BiliRoamingApi
import me.iacn.biliroaming.network.BiliRoamingApi.getPlayUrl
import me.iacn.biliroaming.network.BiliRoamingApi.mainlandTestParams
import me.iacn.biliroaming.network.BiliRoamingApi.overseaTestParams
import me.iacn.biliroaming.utils.*
import org.json.JSONObject
import java.net.URL
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
data class SpeedTestResult(val name: String, val value: String, var speed: String)
class SpeedTestAdapter(context: Context) : ArrayAdapter<SpeedTestResult>(context, 0) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
return (convertView ?: context.inflateLayout(R.layout.cdn_speedtest_item)).apply {
getItem(position).let {
findViewById<TextView>(R.id.upos_name).text = it?.name
findViewById<TextView>(R.id.upos_speed).text =
XposedInit.moduleRes.getString(R.string.speed_formatter, it?.speed)
}
}
}
fun sort() = sort { a, b ->
val aSpeed = a.speed.toLongOrNull()
val bSpeed = b.speed.toLongOrNull()
if (aSpeed == null && bSpeed == null)
0
else if (aSpeed == null)
1
else if (bSpeed == null)
-1
else
(bSpeed - aSpeed).toInt()
}
}
class SpeedTestDialog(activity: Activity, prefs: SharedPreferences) :
AlertDialog.Builder(activity) {
private val scope = MainScope()
private val speedTestDispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher()
private val view = ListView(activity)
private val adapter = SpeedTestAdapter(activity)
init {
view.adapter = adapter
view.addHeaderView(context.inflateLayout(R.layout.cdn_speedtest_item).apply {
findViewById<TextView>(R.id.upos_name).text = XposedInit.moduleRes.getString(R.string.upos)
findViewById<TextView>(R.id.upos_speed).text = XposedInit.moduleRes.getString(R.string.speed)
}, null, false)
view.setPadding(16.dp, 10.dp, 16.dp, 10.dp)
setView(view)
setPositiveButton("关闭", null)
setOnDismissListener {
scope.cancel()
}
view.setOnItemClickListener { _, _, pos, _ ->
val (name, value, _) = adapter.getItem(pos - 1/*headerView*/)
?: return@setOnItemClickListener
Log.d("Use UPOS Server $name: $value")
prefs.edit().putString("upos_host", value).apply()
Log.toast("已启用 UPOS 服务器:${name}", force = true)
}
setTitle("CDN 测速")
}
override fun show(): AlertDialog {
val dialog = super.show()
scope.launch {
dialog.setTitle("正在测速……")
val url = getTestUrl() ?: run {
dialog.setTitle("测速失败")
return@launch
}
XposedInit.moduleRes.getStringArray(R.array.upos_entries)
.zip(XposedInit.moduleRes.getStringArray(R.array.upos_values)).asFlow().map {
scope.launch {
val item = SpeedTestResult(it.first, it.second, "...")
adapter.add(item)
adapter.sort()
val speed = speedTest(it.second, url)
item.speed = speed.toString()
adapter.sort()
}
}.toList().joinAll()
dialog.setTitle("测速完成")
}
return dialog
}
@Suppress("BlockingMethodInNonBlockingContext") // Fuck JetBrain
private suspend fun speedTest(upos: String, rawUrl: String) = try {
withContext(speedTestDispatcher) {
withTimeout(5000) {
val url = if (upos == "\$1") URL(rawUrl) else {
URL(Uri.parse(rawUrl).buildUpon().authority(upos).build().toString())
}
val connection = url.openConnection()
connection.connectTimeout = 5000
connection.readTimeout = 5000
connection.setRequestProperty("User-Agent", "Bilibili Freedoooooom/MarkII")
connection.connect()
val buffer = ByteArray(2048)
var size = 0
val start = System.currentTimeMillis()
connection.getInputStream().use { stream ->
while (isActive) {
val read = stream.read(buffer)
if (read <= 0) break
size += read
}
}
size / (System.currentTimeMillis() - start) // KB/s
}
}
} catch (e: Throwable) {
0L
}
private suspend fun getTestUrl() = try {
withContext(speedTestDispatcher) {
withTimeout(5000) {
val cn = runCatchingOrNull { XposedInit.country.get(5L, TimeUnit.SECONDS) } == "cn"
val json = if (cn) {
getPlayUrl(overseaTestParams, arrayOf("hk", "tw"))
} else getPlayUrl(mainlandTestParams, arrayOf("cn"))
json?.toJSONObject()?.optJSONObject("dash")?.getJSONArray("audio")
?.asSequence<JSONObject>()
?.minWithOrNull { a, b -> a.optInt("bandwidth") - b.optInt("bandwidth") }
?.optString("base_url")?.replace("https", "http")
}
}
} catch (e: BiliRoamingApi.CustomServerException) {
Log.w("请求解析服务器发生错误: ${e.message}")
null
} catch (e: Throwable) {
null
}
}
@@ -0,0 +1,137 @@
@file:Suppress("DEPRECATION")
package me.iacn.biliroaming
import android.annotation.SuppressLint
import android.app.Activity
import android.app.AlertDialog
import android.app.Fragment
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.CheckBox
import android.widget.ListView
import android.widget.TextView
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.inflateLayout
import me.iacn.biliroaming.utils.toJSONObject
import java.io.File
class VideoExportDialog(activity: Activity, fragment: Fragment) : AlertDialog.Builder(activity) {
companion object {
/**
* 指向视频页的文件
*/
var videosToExport = emptySet<File>()
}
private val view = ListView(activity)
private val selectedVideos = mutableSetOf<File>()
init {
val allVideos = mutableListOf<VideoEntry>()
File(activity.externalCacheDir, "../download").listFiles()?.forEach { video ->
video.listFiles()?.forEach { page ->
try {
val jsonObj = File(page, "entry.json").readText().toJSONObject()
val pageData = jsonObj.optJSONObject("page_data")?.let {
VideoEntry.PageData(
it.optString("part")
)
}
val ep = jsonObj.optJSONObject("ep")?.let {
VideoEntry.Ep(
it.optLong("av_id"),
it.optString("bvid"),
it.optString("index"),
it.optString("index_title")
)
}
val videoEntry = VideoEntry(
jsonObj.optString("title"),
jsonObj.optLong("avid"),
jsonObj.optString("bvid"),
pageData, ep
)
videoEntry.path = page
allVideos.add(videoEntry)
} catch (e: Throwable) {
Log.toast("${e.message}", true, alsoLog = true)
}
}
}
view.adapter = VideoExportAdapter(activity, allVideos, selectedVideos)
view.setPadding(50, 20, 50, 20)
setNegativeButton("取消", null)
setPositiveButton("导出") { _, _ ->
videosToExport = selectedVideos
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
try {
fragment.startActivityForResult(
Intent.createChooser(intent, "导出视频"),
SettingDialog.VIDEO_EXPORT
)
} catch (ex: ActivityNotFoundException) {
Log.toast("请安装文件管理器")
}
}
setView(view)
}
class VideoEntry(
val title: String,
val avid: Long,
val bvid: String,
val pageData: PageData?,
val ep: Ep?,
var path: File? = null
) {
class PageData(
val part: String
)
class Ep(
val avid: Long,
val bvid: String,
val index: String,
val indexTitle: String
)
val aBvid
get() = ep?.bvid ?: bvid
val aid get() = ep?.avid ?: avid
val pageTitle get() = pageData?.part ?: ep?.let { "${it.index} ${it.indexTitle}" }
}
class VideoExportAdapter(
context: Context,
private val allVideos: List<VideoEntry>,
private val selectedVideos: MutableSet<File>
) : ArrayAdapter<VideoEntry>(context, 0) {
@SuppressLint("SetTextI18n")
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
return (convertView ?: context.inflateLayout(R.layout.video_choose)).apply {
allVideos[position].let {
findViewById<TextView>(R.id.tv_title).text = it.title
findViewById<TextView>(R.id.tv_pageTitle).text = it.pageTitle
findViewById<TextView>(R.id.tv_aid).text = "av${it.aid}"
findViewById<TextView>(R.id.tv_bvid).text = it.aBvid
findViewById<CheckBox>(R.id.cb).setOnCheckedChangeListener { _, isChecked ->
if (isChecked)
it.path?.let { it1 -> selectedVideos.add(it1) }
else
selectedVideos.remove(it.path)
}
}
}
}
override fun getCount() = allVideos.size
}
}
@@ -0,0 +1,223 @@
package me.iacn.biliroaming
import android.app.Activity
import android.app.Application
import android.app.Instrumentation
import android.content.Context
import android.content.res.Resources
import android.content.res.XModuleResources
import android.os.Build
import de.robv.android.xposed.IXposedHookLoadPackage
import de.robv.android.xposed.IXposedHookZygoteInit
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.future.future
import me.iacn.biliroaming.hook.*
import me.iacn.biliroaming.hook.KotlinxJsonHook
import me.iacn.biliroaming.utils.*
import java.util.concurrent.CompletableFuture
/**
* Created by iAcn on 2019/3/24
* Email i@iacn.me
*/
class XposedInit : IXposedHookLoadPackage, IXposedHookZygoteInit {
override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) {
modulePath = startupParam.modulePath
moduleRes = getModuleRes(modulePath)
}
override fun handleLoadPackage(lpparam: LoadPackageParam) {
if (BuildConfig.APPLICATION_ID == lpparam.packageName) {
MainActivity.Companion::class.java.name.replaceMethod(
lpparam.classLoader,
"isModuleActive"
) { true }
return
}
if (!Constant.BILIBILI_PACKAGE_NAME.containsValue(lpparam.packageName) &&
"tv.danmaku.bili.MainActivityV2".findClassOrNull(lpparam.classLoader) == null
) return
Instrumentation::class.java.hookBeforeMethod(
"callApplicationOnCreate",
Application::class.java
) { param ->
// Hook main process and download process
when {
!lpparam.processName.contains(":") -> {
if (shouldSaveLog) {
startLog()
}
Log.d("BiliBili process launched ...")
Log.d("BiliRoaming version: ${BuildConfig.VERSION_NAME}(${BuildConfig.VERSION_CODE}) from $modulePath${if (isBuiltIn) "(BuiltIn)" else ""}")
Log.d("Bilibili version: ${getPackageVersion(lpparam.packageName)} (${if (is64) "64" else "32"}bit)")
Log.d("SDK: ${Build.VERSION.RELEASE}(${Build.VERSION.SDK_INT}); Phone: ${Build.BRAND} ${Build.MODEL}")
Log.d("Config: ${sPrefs.all}")
Log.toast(
"哔哩漫游已激活${
if (sPrefs.getBoolean("main_func", false) &&
(!sPrefs.getString("hk_server", null).isNullOrEmpty() ||
!sPrefs.getString("th_server", null).isNullOrEmpty() ||
!sPrefs.getString("tw_server", null).isNullOrEmpty() ||
!sPrefs.getString("cn_server", null).isNullOrEmpty())
) ""
else "。\n但未启用番剧解锁功能,请检查解析服务器设置。"
}\n请勿在B站任何地方宣传漫游。\n漫游插件开源免费,谨防被骗。"
)
country = MainScope().future(Dispatchers.IO) {
when (fetchJson(Constant.zoneUrl)?.optJSONObject("data")?.optInt("country_code", 0)?.or(0)) {
86 -> "cn"
852, 853 -> "hk"
886 -> "tw"
else -> "global"
}.also { Log.d("当前地区: $it") }
}
BiliBiliPackage(lpparam.classLoader, param.args[0] as Context)
if (BuildConfig.DEBUG) {
startHook { SSLHook(lpparam.classLoader) }
}
startHook { KillDelayBootHook(lpparam.classLoader) }
startHook { HintHook(lpparam.classLoader) }
startHook { BangumiSeasonHook(lpparam.classLoader) }
startHook { BangumiPlayUrlHook(lpparam.classLoader) }
startHook { PegasusHook(lpparam.classLoader) }
startHook { CustomThemeHook(lpparam.classLoader) }
startHook { TeenagersModeHook(lpparam.classLoader) }
startHook { JsonHook(lpparam.classLoader) }
startHook { GsonHook(lpparam.classLoader) }
startHook { KotlinxJsonHook(lpparam.classLoader) }
startHook { ShareHook(lpparam.classLoader) }
startHook { AutoLikeHook(lpparam.classLoader) }
startHook { SettingHook(lpparam.classLoader) }
startHook { SplashHook(lpparam.classLoader) }
startHook { EnvHook(lpparam.classLoader) }
startHook { DownloadThreadHook(lpparam.classLoader) }
startHook { MusicNotificationHook(lpparam.classLoader) }
startHook { DrawerHook(lpparam.classLoader) }
startHook { CoverHook(lpparam.classLoader) }
startHook { SubtitleHook(lpparam.classLoader) }
startHook { CopyHook(lpparam.classLoader) }
startHook { CopyCommentHook(lpparam.classLoader) }
startHook { LiveRoomHook(lpparam.classLoader) }
startHook { QualityHook(lpparam.classLoader) }
startHook { DynamicHook(lpparam.classLoader) }
startHook { ProtoBufHook(lpparam.classLoader) }
startHook { PlayArcConfHook(lpparam.classLoader) }
startHook { TryWatchVipQualityHook(lpparam.classLoader) }
startHook { AllowMiniPlayHook(lpparam.classLoader) }
startHook { StartActivityHook(lpparam.classLoader) }
startHook { FullStoryHook(lpparam.classLoader) }
startHook { DialogBlurBackgroundHook(lpparam.classLoader) }
startHook { PlayerLongPressHook(lpparam.classLoader) }
startHook { BlockUpdateHook(lpparam.classLoader) }
startHook { VipSectionHook(lpparam.classLoader) }
startHook { CommentImageHook(lpparam.classLoader) }
startHook { WebViewHook(lpparam.classLoader) }
startHook { P2pHook(lpparam.classLoader) }
startHook { DanmakuHook(lpparam.classLoader) }
startHook { BangumiPageAdHook(lpparam.classLoader) }
startHook { VideoQualityHook(lpparam.classLoader) }
// startHook(PublishToFollowingHook(lpparam.classLoader))
startHook { UposReplaceHook(lpparam.classLoader) }
startHook { SpeedHook(lpparam.classLoader) }
startHook { MultiWindowHook(lpparam.classLoader) }
startHook { LiveQualityHook(lpparam.classLoader) }
startHook { StoryPlayerAdHook(lpparam.classLoader) }
startHook { LongPressSpeed(lpparam.classLoader) }
startHook { SkipVideoAd(lpparam.classLoader) }
}
lpparam.processName.endsWith(":web") -> {
BiliBiliPackage(lpparam.classLoader, param.args[0] as Context)
CustomThemeHook(lpparam.classLoader).insertColorForWebProcess()
startHook { WebViewHook(lpparam.classLoader) }
startHook { ShareHook(lpparam.classLoader) }
startHook { DialogBlurBackgroundHook(lpparam.classLoader) }
startHook { RewardAdHook(lpparam.classLoader) }
}
lpparam.processName.endsWith(":download") -> {
BiliBiliPackage(lpparam.classLoader, param.args[0] as Context)
startHook { BangumiPlayUrlHook(lpparam.classLoader) }
}
}
}
lateInitHook = Activity::class.java.hookBeforeMethod("onResume") {
startLateHook()
lateInitHook?.unhook()
}
}
@Deprecated(
"Use startHook(hookerCreater: () -> BaseHook) instead",
ReplaceWith("startHook { hooker }")
)
private fun startHook(hooker: BaseHook) {
startHook { hooker }
}
private fun startHook(hookerCreater: () -> BaseHook) {
try {
val hooker = hookerCreater()
hookers.add(hooker)
hooker.startHook()
} catch (e: Throwable) {
Log.e(e)
Log.toast("出现错误\n${e.message}\n部分功能可能失效。${e.stackTrace.joinToString("\n")}")
}
}
private fun startLateHook() {
hookers.forEach {
try {
it.lateInitHook()
} catch (e: Throwable) {
Log.e(e)
Log.toast("出现错误\n${e.message}\n部分功能可能失效。${e.stackTrace.joinToString("\n")}")
}
}
}
private fun startLog() = try {
if (logFile.exists()) {
if (oldLogFile.exists()) {
oldLogFile.delete()
}
logFile.renameTo(oldLogFile)
}
logFile.delete()
logFile.createNewFile()
Runtime.getRuntime().exec(
arrayOf(
"logcat",
"-T",
"100",
"-f",
logFile.absolutePath
)
)
} catch (e: Throwable) {
Log.e(e)
null
}
companion object {
lateinit var modulePath: String
lateinit var moduleRes: Resources
lateinit var country: CompletableFuture<String>
private val hookers = ArrayList<BaseHook>()
private var lateInitHook: XC_MethodHook.Unhook? = null
@JvmStatic
fun getModuleRes(path: String): Resources {
return XModuleResources.createInstance(path, null)
}
}
}
@@ -0,0 +1,37 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.utils.from
import me.iacn.biliroaming.utils.getStaticObjectField
import me.iacn.biliroaming.utils.hookBeforeAllConstructors
import me.iacn.biliroaming.utils.hookBeforeConstructor
import me.iacn.biliroaming.utils.sPrefs
class AllowMiniPlayHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("main_func", false)) return
if (sPrefs.getBoolean("allow_mini_play", false)) {
val miniPlayerType =
"com.bilibili.lib.media.resource.PlayConfig\$PlayConfigType"
.from(mClassLoader)?.getStaticObjectField("MINIPLAYER")
"com.bilibili.lib.media.resource.PlayConfig\$PlayMenuConfig".from(mClassLoader)?.run {
hookBeforeConstructor(
Boolean::class.javaPrimitiveType,
"com.bilibili.lib.media.resource.PlayConfig\$PlayConfigType"
) { param ->
val type = param.args[1]
if (type == miniPlayerType)
param.args[0] = true
}
hookBeforeConstructor(Boolean::class.javaPrimitiveType,
"com.bilibili.lib.media.resource.PlayConfig\$PlayConfigType",
List::class.java
) { param ->
val type = param.args[1]
if (type == miniPlayerType)
param.args[0] = true
}
}
}
}
}
@@ -0,0 +1,120 @@
package me.iacn.biliroaming.hook
import android.view.View
import android.widget.LinearLayout
import de.robv.android.xposed.XC_MethodHook.Unhook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
class AutoLikeHook(classLoader: ClassLoader) : BaseHook(classLoader) {
private val likedVideos = HashSet<Long>()
companion object {
var detail: Pair<Long, Int>? = null
}
private val likeIds by lazy {
arrayOf(
"frame_like",
"like_layout"
).map { getId(it) }
}
override fun startHook() {
if (!sPrefs.getBoolean("auto_like", false)) return
Log.d("startHook: AutoLike")
val unhookSet = arrayOf<Set<Unhook>?>(null)
unhookSet[0] = instance.kingPositionComponentClass?.hookAfterAllConstructors { param ->
val kingPositionComponent = param.thisObject
val componentMap =
kingPositionComponent.getObjectFieldAs<Map<*, *>>(instance.componentMapField())
val likeComponentClass = componentMap.keys.filterNotNull().firstNotNullOfOrNull {
val componentClass = it.javaClass
componentClass.declaredFields.firstOrNull {
it.type == Runnable::class.java
} ?: return@firstNotNullOfOrNull null
componentClass
} ?: return@hookAfterAllConstructors
val rebindView = likeComponentClass.declaredMethods.firstOrNull {
it.returnType == Void.TYPE && it.parameterCount == 5
&& it.parameterTypes[0] == likeComponentClass
&& it.parameterTypes[4] == LinearLayout::class.java
} ?: return@hookAfterAllConstructors
rebindView.hookAfterMethod { p ->
performLike(p.args[4] as View)
}
unhookSet[0]?.forEach { it.unhook() }
}
// 番剧分集切换
instance.viewUniteMossClass?.hookBeforeMethod(
"arcRefresh",
"com.bapis.bilibili.app.viewunite.v1.ArcRefreshReq",
instance.mossResponseHandlerClass
) { param ->
val req = param.args[0]
val aid = req.callMethodAs("getAid")
?: req.callMethodAs<String?>("getBvid")?.let { bv2av(it) }
if (aid == null || aid <= 0L) {
return@hookBeforeMethod
}
param.args[1] = param.args[1].mossResponseHandlerReplaceProxy { reply ->
reply ?: return@mossResponseHandlerReplaceProxy null
val like = reply.callMethod("getReqUser")?.callMethodAs<Int?>("getLike")
?: return@mossResponseHandlerReplaceProxy null
detail = aid to like
null
}
}
// 竖屏模式
instance.storyAbsControllerClass?.hookAfterMethod(
instance.setMDataMethod(),
instance.storyDetailClass
) { param ->
if (param.thisObject.callMethod(instance.getMPlayerMethod()) == null) {
return@hookAfterMethod
}
val storyDetail = instance.fastJsonClass?.callStaticMethodAs<String>(
"toJSONString",
param.args[0]
).toJSONObject()
val playerArgs = storyDetail.optJSONObject("player_args")
val aid = playerArgs?.optLong("aid")
val reqUser = storyDetail.optJSONObject("req_user")
val like = reqUser?.optBoolean("like")
if (aid == null || like == null) {
return@hookAfterMethod
}
detail = aid to if (like) 1 else 0
performLike(param.thisObject as View)
}
}
private fun performLike(root: View) {
val likeView = likeIds.firstNotNullOfOrNull {
root.findViewById(it)
}
likeView?.post {
if (shouldClickLike()) {
likeView.callOnClick()
}
}
}
private fun shouldClickLike(): Boolean {
val (aid, like) = detail ?: return false
if (likedVideos.contains(aid) || like != 0) {
return false
}
likedVideos.add(aid)
return true
}
}
@@ -0,0 +1,32 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
class BangumiPageAdHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("block_view_page_ads", false)) return
Log.d("startHook: BangumiPageAd")
val oGVActivityVoHooker: Hooker = { param ->
val args = param.args
for (i in args.indices) {
when (val item = args[i]) {
is Int -> args[i] = 0
is MutableList<*> -> item.clear()
else -> args[i] = null
}
}
}
// activity toast ad
"com.bilibili.bangumi.data.page.detail.entity.OGVActivityVo".from(mClassLoader)
?.hookBeforeAllConstructors(oGVActivityVoHooker)
"com.bilibili.ship.theseus.ogv.activity.OGVActivityVo".from(mClassLoader)
?.hookBeforeAllConstructors(oGVActivityVoHooker)
// mall
instance.bangumiUniformSeasonActivityEntrance()?.let {
instance.bangumiUniformSeasonClass?.replaceMethod(it) { null }
}
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,10 @@
package me.iacn.biliroaming.hook
/**
* Created by iAcn on 2019/3/27
* Email i@iacn.me
*/
abstract class BaseHook(val mClassLoader: ClassLoader) {
abstract fun startHook()
open fun lateInitHook() {}
}
@@ -0,0 +1,20 @@
package me.iacn.biliroaming.hook
import android.content.Context
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.hookBeforeMethod
import me.iacn.biliroaming.utils.new
import me.iacn.biliroaming.utils.sPrefs
class BlockUpdateHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("block_update", false)) return
instance.updateInfoSupplierClass?.hookBeforeMethod(
instance.check(), Context::class.java
) { param ->
val message = "哼,休想要我更新!<( ̄︶ ̄)>"
param.throwable = instance.latestVersionExceptionClass?.new(message) as? Throwable
?: Exception(message)
}
}
}
@@ -0,0 +1,97 @@
package me.iacn.biliroaming.hook
import android.content.ContentValues
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.Parcelable
import android.provider.MediaStore
import android.view.HapticFeedbackConstants
import android.view.View
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
class CommentImageHook(classLoader: ClassLoader) : BaseHook(classLoader) {
companion object {
fun saveImage(url: String) = runCatching {
URL(url).openStream().use { stream ->
val relativePath = "${Environment.DIRECTORY_PICTURES}${File.separator}bili"
val fullFilename = url.substringAfterLast('/')
val filename = fullFilename.substringBeforeLast('.')
val now = System.currentTimeMillis()
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
put(
MediaStore.MediaColumns.MIME_TYPE,
HttpURLConnection.guessContentTypeFromName(fullFilename) ?: "image/png"
)
put(MediaStore.MediaColumns.DATE_ADDED, now / 1000)
put(MediaStore.MediaColumns.DATE_MODIFIED, now / 1000)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.MediaColumns.DATE_TAKEN, now)
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
} else {
val path = File(
Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_PICTURES
), "bili"
).also { it.mkdirs() }
put(MediaStore.MediaColumns.DATA, File(path, fullFilename).absolutePath)
}
}
val resolver = currentContext.contentResolver
val uri = resolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues
)
runCatching {
resolver.openOutputStream(uri!!)?.use { stream.copyTo(it) }
}.onSuccess {
Log.toast("图片已保存至\n$relativePath${File.separator}$fullFilename", true)
}.onFailure {
Log.e(it)
Log.toast("图片保存失败,可能已经保存或未授予权限", true)
}
}
}.onFailure {
Log.e(it)
Log.toast("图片获取失败", force = true)
}
}
private val imageViewId = getId("image_view")
private var cacheUrlFieldName = ""
@Suppress("DEPRECATION")
override fun startHook() {
if (!sPrefs.getBoolean("save_comment_image", false)) return
instance.imageFragmentClass?.hookAfterMethod(
"onViewCreated", View::class.java, Bundle::class.java
) { param ->
val self = param.thisObject
val view = param.args[0] as? View
val imageItem = self.callMethodOrNullAs<Bundle?>("getArguments")
?.getParcelable<Parcelable>("image_item") ?: return@hookAfterMethod
val urlFieldName = cacheUrlFieldName.ifEmpty {
imageItem.javaClass.superclass.findFirstFieldByExactTypeOrNull(String::class.java)
?.name.orEmpty().also { cacheUrlFieldName = it }
}.ifEmpty { return@hookAfterMethod }
val imageUrl = imageItem.getObjectFieldAs<String?>(urlFieldName).takeIf {
!it.isNullOrEmpty() && it.startsWith("http")
}?.substringBefore('@') ?: return@hookAfterMethod
view?.findViewById<View>(imageViewId)?.setOnLongClickListener {
it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
MainScope().launch(Dispatchers.IO) {
saveImage(imageUrl)
}
true
}
}
}
}
@@ -0,0 +1,49 @@
package me.iacn.biliroaming.hook
import android.app.AlertDialog
import android.content.ClipboardManager
import android.content.Context
import android.view.View
import android.widget.TextView
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedHelpers
import me.iacn.biliroaming.utils.currentContext
import me.iacn.biliroaming.utils.getResId
import me.iacn.biliroaming.utils.sPrefs
class CopyCommentHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("copy_comment", false)) return
XposedHelpers.findAndHookMethod(
"com.bilibili.app.comment3.ui.widget.menu.CommentMoreMenuItemHolder",
mClassLoader,
"y3",
"kotlin.jvm.functions.Function1",
"com.bilibili.app.comment3.data.model.CommentItem\$MenuItem",
"android.view.View", // ConstraintLayout
object : XC_MethodHook() {
override fun afterHookedMethod(param: MethodHookParam) {
super.afterHookedMethod(param)
val menu = param.args[1]
val view = param.args[2] as View
if (!menu.toString().contains("COPY")) return
val clipboard =
currentContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
// 实在是找不到了。那你就说有没有获取到吧
val txt = clipboard.primaryClip!!.getItemAt(0).text
AlertDialog.Builder(view.context, getResId("AppTheme.Dialog.Alert", "style"))
.run {
setTitle("自由复制内容")
setMessage(txt)
setPositiveButton("完成") { _, _ -> }
setNegativeButton("复制全部") { _, _ -> }
show()
}.apply {
findViewById<TextView>(android.R.id.message).setTextIsSelectable(true)
}
}
})
}
}
@@ -0,0 +1,186 @@
package me.iacn.biliroaming.hook
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.text.SpannableStringBuilder
import android.text.style.ClickableSpan
import android.view.View
import android.widget.FrameLayout
import android.widget.PopupWindow
import android.widget.TextView
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
import org.json.JSONObject
class CopyHook(classLoader: ClassLoader) : BaseHook(classLoader) {
companion object {
private val DYNAMIC_COPYABLE_IDS = arrayOf(
"dy_card_text",
"dy_opus_paragraph_desc",
"dy_opus_paragraph_title",
"dy_opus_copy_right_id",
"dy_opus_paragraph_text",
)
}
private val enhanceLongClickCopy = sPrefs.getBoolean("comment_copy_enhance", false)
override fun startHook() {
if (!sPrefs.getBoolean("comment_copy", false)) return
instance.descCopyView().zip(instance.descCopy()).forEach { p ->
val clazz = p.first ?: return@forEach
val method = p.second ?: return@forEach
clazz.replaceMethod(
method,
View::class.java,
ClickableSpan::class.java
) { param ->
if (!enhanceLongClickCopy) return@replaceMethod Unit
param.thisObject.getFirstFieldByExactTypeOrNull<SpannableStringBuilder>()?.let {
val view = param.args[0] as View
showCopyDialog(view.context, it, param)
} ?: (param.args[0] as? TextView)?.let { tv ->
showCopyDialog(tv.context, tv.text, param)
}
}
}
instance.dynamicDescHolderListeners().forEach { c ->
c?.replaceMethod("onLongClick", View::class.java) { param ->
if (!enhanceLongClickCopy)
return@replaceMethod true
val itemView = param.args[0] as? View
DYNAMIC_COPYABLE_IDS.asSequence().firstNotNullOfOrNull { n ->
getId(n).takeIf { it != 0 }?.let { itemView?.findViewById<TextView>(it) }
}?.let { v ->
(if (instance.ellipsizingTextViewClass?.isInstance(v) == true) {
v.getFirstFieldByExactTypeOrNull()
} else v.text)?.also { text ->
showCopyDialog(v.context, text, param)
}
} ?: Log.toast("找不到动态内容", true)
true
}
}
val commentCopyHook = fun(param: MethodHookParam, idName: String): Any? {
if (!enhanceLongClickCopy) return true
if (param.args[0] is FrameLayout) return param.invokeOriginalMethod()
(param.args[0] as? View)?.findViewById<View>(getId(idName))?.let {
if (instance.commentSpanTextViewClass?.isInstance(it) == true ||
instance.commentSpanEllipsisTextViewClass?.isInstance(it) == true
) it else null
}?.let { view ->
view.getFirstFieldByExactTypeOrNull<CharSequence>()?.also { text ->
showCopyDialog(view.context, text, param)
}
} ?: Log.toast("找不到评论内容", true)
return true
}
instance.commentCopyClass?.replaceMethod("onLongClick", View::class.java) {
commentCopyHook(it, "message")
}
instance.commentCopyNewClass?.replaceMethod("onLongClick", View::class.java) {
commentCopyHook(it, "comment_message")
}
instance.comment3CopyClass?.let { c ->
instance.comment3Copy()?.let { m ->
instance.comment3ViewIndex().let { i ->
c.replaceAllMethods(m) { param ->
if (!enhanceLongClickCopy) return@replaceAllMethods true
val view = param.args[i] as View
view.getFirstFieldByExactTypeOrNull<CharSequence>()?.also { text ->
showCopyDialog(view.context, text, param)
}
return@replaceAllMethods true
}
}
}
}
if (!enhanceLongClickCopy) return
val onClickName = instance.onOperateClick() ?: return
val contentStringName = instance.getContentString() ?: return
val hostClassName = instance.operateClickHostClass() ?: return
val hostClass = hostClassName.from(mClassLoader) ?: return
val hookMethod = hostClass.declaredMethods.find { it.name == onClickName } ?: return
fun parseContentText(json: String): String? = runCatchingOrNull { json.toJSONObject() }?.run {
optString("content").ifEmpty {
buildString {
appendLine(optString("title").trim())
appendLine(optString("text").trim())
optJSONArray("modules")?.run {
asSequence<JSONObject>().map {
it.optString("title") + ":" + it.optString("detail")
}.joinToString("\n").run { append(this) }
}
}.run { removeSuffix("\n") }
}.ifEmpty { null }
}
hookMethod.hookBeforeMethod { param ->
// Repost guard: last arg == first arg
if (param.args.size >= 2 && param.args.last() != param.args.first()) return@hookBeforeMethod
val hostClass = param.thisObject.javaClass
// Activity: try this first, then search for captured field
val activity = (param.thisObject as? Activity)
?: hostClass.declaredFields.find {
Activity::class.java.isAssignableFrom(it.type)
}?.apply { isAccessible = true }?.get(param.thisObject) as? Activity
?: return@hookBeforeMethod
// Typed message: try args[1] first, then search for field with getContentString
val typedMsg = param.args.getOrNull(1)
?: hostClass.declaredFields.find {
runCatching { it.type.getMethod(contentStringName) }.isSuccess
}?.apply { isAccessible = true }?.get(param.thisObject)
?: return@hookBeforeMethod
val json = typedMsg.callMethodOrNullAs<String>(contentStringName) ?: return@hookBeforeMethod
val text = parseContentText(json) ?: return@hookBeforeMethod
showCopyDialog(activity, text, param)
// Dismiss popup: try args[6] first, then search for PopupWindow field
(param.args.getOrNull(6)
?: hostClass.declaredFields.find {
PopupWindow::class.java.isAssignableFrom(it.type)
}?.apply { isAccessible = true }?.get(param.thisObject))
?.callMethodOrNull("dismiss")
param.result = null
}
}
private fun showCopyDialog(context: Context, text: CharSequence, param: MethodHookParam) {
val appDialogTheme = getResId("AppTheme.Dialog.Alert", "style")
AlertDialog.Builder(context, appDialogTheme).run {
setTitle("自由复制内容")
setMessage(text)
setPositiveButton("分享") { _, _ ->
context.startActivity(
Intent.createChooser(
Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, text)
type = "text/plain"
}, "分享评论内容"
)
)
}
setNeutralButton("复制全部") { _, _ ->
param.invokeOriginalMethod()
}
setNegativeButton(android.R.string.cancel, null)
show()
}.apply {
findViewById<TextView>(android.R.id.message).setTextIsSelectable(true)
}
}
}
@@ -0,0 +1,214 @@
package me.iacn.biliroaming.hook
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ContentValues
import android.graphics.Bitmap
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
import java.io.File
class CoverHook(classLoader: ClassLoader) : BaseHook(classLoader) {
private val gson by lazy {
instance.gson()?.let { instance.gsonConverterClass?.getStaticObjectField(it) }
}
override fun startHook() {
// if (!sPrefs.getBoolean("get_cover", false)) return
// Log.d("startHook: GetCover")
// arrayOf(bgmClass, ugcClass, liveClass).forEach {
// it?.hookAfterMethod(
// "onViewCreated",
// View::class.java,
// Bundle::class.java,
// hooker = hooker
// )
// }
}
@SuppressLint("ClickableViewAccessibility")
val hooker: Hooker = { param ->
val group = param.args[0] as ViewGroup
val activity = param.thisObject.callMethodAs<Activity>("getActivity")
val gestureDetector =
GestureDetector(activity, object : GestureDetector.SimpleOnGestureListener() {
override fun onLongPress(e: MotionEvent) {
var url: String? = null
var filename: String? = null
var title: String? = null
try {
when (param.thisObject.javaClass) {
bgmClass -> activity.run {
val viewModelField =
activity.javaClass.declaredFields.firstOrNull { it.type.name == "com.bilibili.bangumi.logic.page.detail.BangumiDetailViewModelV2" }
val episodeMethod =
viewModelField?.type?.declaredMethods?.lastOrNull { it.returnType.name == "com.bilibili.bangumi.data.page.detail.entity.BangumiUniformEpisode" }
val episode =
getObjectField(viewModelField?.name)?.callMethod(episodeMethod?.name)
val hasGson = episode?.javaClass?.annotations?.any {
it.annotationClass.java.name.startsWith("gsonannotator")
} ?: false && instance.gsonFromJson() != null && instance.gsonToJson() != null
if (hasGson) {
val json =
gson?.callMethodAs<String>(instance.gsonToJson(), episode)
?.toJSONObject()
url = json?.optString("cover")
filename = "ep${json?.optInt("id")}"
title = json?.optString("share_copy")
} else {
url = episode?.getObjectFieldAs("cover")
filename = "ep${episode?.getLongField("epid").toString()}"
title = episode?.getObjectFieldAs("longTitle") ?: ""
}
}
ugcClass -> activity.run {
javaClass.declaredFields.firstOrNull { it.type == instance.biliVideoDetailClass }
?.let {
url = getObjectField(it.name)?.getObjectFieldAs("mCover")
filename = "av${
getObjectField(it.name)?.getObjectField("mAvid")
.toString()
}"
title = getObjectField(it.name)?.getObjectFieldAs("mTitle")
}
}
else -> if (liveClass?.isInstance(param.thisObject) == true) {
val viewModelField = activity.javaClass.declaredFields.firstOrNull {
it.type.name == "com.bilibili.bililive.videoliveplayer.ui.roomv3.base.viewmodel.LiveRoomRootViewModel" ||
it.type.name == "com.bilibili.bililive.room.ui.roomv3.base.viewmodel.LiveRoomRootViewModel"
}
val roomFeedField =
viewModelField?.type?.declaredFields?.firstOrNull {
it.type.name == "com.bilibili.bililive.videoliveplayer.ui.roomv3.vertical.roomfeed.LiveRoomFeedViewModel" ||
it.type.name == "com.bilibili.bililive.room.ui.roomv3.vertical.roomfeed.LiveRoomFeedViewModel"
}
val currentFeedField =
roomFeedField?.type?.declaredFields?.firstOrNull {
it.type.name.startsWith("com.bilibili.bililive.videoliveplayer.ui.roomv3.vertical.roomfeed") ||
it.type.name.startsWith("com.bilibili.bililive.room.ui.roomv3.vertical.roomfeed")
}
val roomIdField =
currentFeedField?.type?.declaredFields?.firstOrNull { it.type == Long::class.java }
val coverField =
currentFeedField?.type?.declaredFields?.firstOrNull { it.type == String::class.java }
val titleField =
currentFeedField?.type?.declaredFields?.lastOrNull { it.type == String::class.java }
activity.getObjectField(viewModelField?.name)
?.getObjectField(roomFeedField?.name)
?.getObjectField(currentFeedField?.name)?.run {
url = getObjectFieldAs(coverField?.name)
filename =
"live${getObjectField(roomIdField?.name).toString()}"
title = getObjectFieldAs(titleField?.name)
}
}
}
} catch (e: Throwable) {
Log.e(e)
}
Log.toast("开始获取封面", true)
getBitmapFromURL(url) { bitmap ->
bitmap?.let {
val relativePath =
"${Environment.DIRECTORY_PICTURES}${File.separator}bilibili"
@Suppress("DEPRECATION")
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
put(MediaStore.MediaColumns.MIME_TYPE, "image/png")
put(MediaStore.MediaColumns.TITLE, title)
put(
MediaStore.MediaColumns.DATE_ADDED,
System.currentTimeMillis() / 1000
)
put(
MediaStore.MediaColumns.DATE_MODIFIED,
System.currentTimeMillis() / 1000
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(
MediaStore.MediaColumns.DATE_TAKEN,
System.currentTimeMillis()
)
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
} else {
val path = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
"bilibili"
)
path.mkdirs()
put(
MediaStore.MediaColumns.DATA,
File(path, "$filename.png").absolutePath
)
}
}
val resolver = activity.contentResolver
val uri = resolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
)
try {
resolver.openOutputStream(uri!!)?.use { stream ->
it.compress(Bitmap.CompressFormat.PNG, 100, stream)
Log.toast(
"保存封面成功到\n$relativePath${File.separator}$filename.png",
true
)
}
} catch (e: Throwable) {
Log.e(e)
Log.toast("保存封面失败,可能已经保存或未授予权限", true)
}
} ?: run {
Log.toast("获取封面失败", true)
return@getBitmapFromURL
}
}
}
})
val onTouchListener = View.OnTouchListener { _, event ->
gestureDetector.onTouchEvent(event)
false
}
for (i in 0 until group.childCount) {
val view = group.getChildAt(i)
if (view.javaClass.name.startsWith("tv.danmaku.biliplayerv2.widget.gesture")
|| view.javaClass.name.startsWith("tv.danmaku.biliplayerimpl.gesture")
) {
view.setOnTouchListener(onTouchListener)
}
}
val liveId = getId("controller_underlay")
activity.findViewById<View>(liveId)?.setOnTouchListener(onTouchListener)
}
val bgmClass by Weak {
"com.bilibili.bangumi.ui.page.detail.playerV2.BangumiPlayerFragmentV2".findClass(
mClassLoader
)
}
val ugcClass by Weak {
"tv.danmaku.bili.ui.video.playerv2.UgcPlayerFragment".findClassOrNull(
mClassLoader
) ?: "tv.danmaku.bili.videopage.player.UgcPlayerFragment".findClassOrNull(mClassLoader)
}
val liveClass by Weak {
"com.bilibili.bililive.blps.core.business.player.container.AbsLivePlayerFragment".findClass(
mClassLoader
)
}
}
@@ -0,0 +1,250 @@
package me.iacn.biliroaming.hook
import android.graphics.Color
import android.util.SparseArray
import android.view.View
import androidx.annotation.ColorInt
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.ColorChooseDialog
import me.iacn.biliroaming.Constant.CURRENT_COLOR_KEY
import me.iacn.biliroaming.Constant.CUSTOM_COLOR_KEY
import me.iacn.biliroaming.Constant.DEFAULT_CUSTOM_COLOR
import me.iacn.biliroaming.utils.*
/**
* Created by iAcn on 2019/7/14
* Email i@iacn.me
*/
class CustomThemeHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("custom_theme", false)) return
Log.d("startHook: CustomTheme")
instance.themeNameClass?.getStaticObjectFieldAs<MutableMap<String, Int>>(instance.themeName())
?.run {
put("custom1", CUSTOM_THEME_ID1)
put("custom2", CUSTOM_THEME_ID2)
}
val colorArray =
instance.themeHelperClass?.getStaticObjectFieldAs<SparseArray<IntArray>>(instance.colorArray())
val primaryColor = customColor
colorArray?.put(CUSTOM_THEME_ID1, generateColorArray(primaryColor))
colorArray?.put(CUSTOM_THEME_ID2, generateColorArray(primaryColor))
val allThemes =
instance.builtInThemesClass?.getStaticObjectFieldAs<MutableMap<Long, Any>>(instance.allThemesField())
instance.skinList()?.let {
"tv.danmaku.bili.ui.theme.ThemeStoreActivity".hookBeforeMethod(
mClassLoader, it,
"tv.danmaku.bili.ui.theme.api.BiliSkinList", Boolean::class.javaPrimitiveType
) { param ->
val biliSkinList = param.args[0]
val mList = biliSkinList.getObjectFieldAs<MutableList<Any>>("mList")
val biliSkin =
"tv.danmaku.bili.ui.theme.api.BiliSkin".findClassOrNull(mClassLoader)?.new()
?: return@hookBeforeMethod
biliSkin.setIntField(
"mId",
if (currentKey == CUSTOM_THEME_ID2) CUSTOM_THEME_ID2 else CUSTOM_THEME_ID1
)
.setObjectField("mName", "自选颜色")
.setBooleanField("mIsFree", true)
// Under the night mode item
mList.add(3, biliSkin)
Log.d("Add a theme item: size = " + mList.size)
}
}
instance.themeListClickClass?.hookBeforeMethod("onClick", View::class.java) { param ->
val view = param.args[0] as View
val idName = view.resources.getResourceEntryName(view.id)
if ("list_item" != idName) return@hookBeforeMethod
val biliSkin = view.tag ?: return@hookBeforeMethod
val mId = biliSkin.getIntField("mId")
// Make colors updated immediately
if (mId == CUSTOM_THEME_ID1 || mId == CUSTOM_THEME_ID2) {
view.context.addModuleAssets()
val colorDialog = ColorChooseDialog(view.context, customColor)
colorDialog.setPositiveButton("确定") { _, _ ->
val color = colorDialog.color
val colors = generateColorArray(color)
colorArray?.put(CUSTOM_THEME_ID1, colors)
colorArray?.put(CUSTOM_THEME_ID2, colors)
color.toTheme()?.let {
allThemes?.put(CUSTOM_THEME_ID1.toLong(), it)
allThemes?.put(CUSTOM_THEME_ID2.toLong(), it)
}
val newId = if (mId == CUSTOM_THEME_ID1) CUSTOM_THEME_ID2 else CUSTOM_THEME_ID1
biliSkin.setIntField("mId", newId)
customColor = color
try {
param.invokeOriginalMethod()
} catch (e: Exception) {
Log.w(e)
}
}
colorDialog.show()
// Stop executing the original method
param.result = null
}
}
// No reset when not logged in
val replacer: Replacer = { param ->
if (Thread.currentThread().stackTrace.any { s ->
s.className == "tv.danmaku.bili.MainActivityV2" && s.methodName == "onPostCreate"
}) null else param.invokeOriginalMethod()
}
instance.themeReset().forEach {
instance.themeProcessorClass?.replaceMethod(it, replacer = replacer)
}
}
override fun lateInitHook() {
if (!sPrefs.getBoolean("custom_theme", false)) return
val primaryColor = customColor
val allThemes =
instance.builtInThemesClass?.getStaticObjectFieldAs<MutableMap<Long, Any>>(instance.allThemesField())
primaryColor.toTheme()?.let {
allThemes?.put(CUSTOM_THEME_ID1.toLong(), it)
allThemes?.put(CUSTOM_THEME_ID2.toLong(), it)
}
}
fun insertColorForWebProcess() {
if (!sPrefs.getBoolean("custom_theme", false)) return
try {
var cacheColor = customColor
var generatedColorArray = generateColorArray(cacheColor)
instance.themeNameClass?.getStaticObjectFieldAs<MutableMap<String, Int>>(instance.themeName())
?.run {
put("custom1", CUSTOM_THEME_ID1)
put("custom2", CUSTOM_THEME_ID2)
}
instance.columnHelperClass?.getStaticObjectFieldAs<SparseArray<IntArray>>(instance.columnColorArray())
?.run {
put(CUSTOM_THEME_ID1, generatedColorArray)
put(CUSTOM_THEME_ID2, generatedColorArray)
}
instance.themeHelperClass?.getStaticObjectFieldAs<SparseArray<IntArray>>(instance.colorArray())
?.run {
put(CUSTOM_THEME_ID1, generatedColorArray)
put(CUSTOM_THEME_ID2, generatedColorArray)
}
instance.themeIdHelperClass?.getStaticObjectFieldAs<SparseArray<Int>>(instance.colorId())
?.run {
put(CUSTOM_THEME_ID1, CUSTOM_THEME_ID1)
put(CUSTOM_THEME_ID2, CUSTOM_THEME_ID2)
}
SparseArray::class.java.hookAfterMethod(
"get",
Int::class.javaPrimitiveType,
Object::class.java
) { param ->
if (param.args[0] != CUSTOM_THEME_ID1 || param.args[0] != CUSTOM_THEME_ID2) return@hookAfterMethod
if (param.result?.javaClass == generatedColorArray.javaClass && param.result == generatedColorArray) {
val newColor = customColor
if (newColor != cacheColor) {
generatedColorArray = generateColorArray(newColor)
cacheColor = newColor
@Suppress("UNCHECKED_CAST")
(param.thisObject as SparseArray<IntArray>).run {
put(CUSTOM_THEME_ID1, generatedColorArray)
put(CUSTOM_THEME_ID2, generatedColorArray)
}
param.result = generatedColorArray
}
}
}
} catch (e: Throwable) {
Log.e(e)
}
}
companion object {
private const val CUSTOM_THEME_ID1 = 114514 // ん?
private const val CUSTOM_THEME_ID2 = 1919810 // ん?
private var customColor: Int
get() = biliPrefs.getInt(CUSTOM_COLOR_KEY, DEFAULT_CUSTOM_COLOR)
set(value) = biliPrefs.edit().putInt(CUSTOM_COLOR_KEY, value).apply()
private val currentKey: Int
get() = blkvPrefs.getInt(CURRENT_COLOR_KEY, 0)
/**
* Color Array
*
*
* index0: color primary e.g. global main color.
* index1: color primary dark e.g. tint when button be pressed.
* index2: color primary light e.g. temporarily not used.
* index3: color primary trans e.g. mini-tv cover on drawer.
*/
@JvmStatic
private fun generateColorArray(primaryColor: Int): IntArray {
val colors = IntArray(4)
val hsv = FloatArray(3)
val result = FloatArray(3)
Color.colorToHSV(primaryColor, hsv)
colors[0] = primaryColor
// Decrease brightness
hsv.copyInto(result)
result[2] -= result[2] * 0.2f
colors[1] = Color.HSVToColor(result)
// Increase brightness
hsv.copyInto(result)
result[2] += result[2] * 0.1f
colors[2] = Color.HSVToColor(result)
// Increase transparency
colors[3] = -0x4c000000 or 0xFFFFFF and colors[1]
return colors
}
private fun @receiver:ColorInt Int.pack() = toLong() and 0xFFFFFFFF shl 32
private fun @receiver:ColorInt Int.toTheme() = instance.themeColorsConstructor?.run {
parameterTypes.let {
val garb = it[0].let { cls ->
try {
cls.new()
} catch (err: NoSuchMethodError) {
// GarbInfo in play v3.20.0
cls.new(
0L, // id
true, // showDarkContent
true, // isPure
"", // homePrimaryBgPath
0L, // primary
0L, // secondary
0L, // background
0L, // textTitle
0L // actionIcon
)
}
}
newInstance(
garb, // garb
it[1].enumConstants[0], // currentDayNight ThemeDayNight#Day
pack(), // primary !!important
pack(), // secondary !!important
Color.WHITE.pack(), // background !!important
Color.WHITE.pack(), // textTitle !!important
Color.WHITE.pack(), // textSubtitle
Color.WHITE.pack(), // textOther
Color.WHITE.pack(), // actionIcon !!important
true, // isPure
)
}
}
}
}
@@ -0,0 +1,42 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
class DanmakuHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
val blockWeight = sPrefs.getInt("danmaku_filter_weight", 0)
val disableVipDmColorful = sPrefs.getBoolean("disable_vip_dm_colorful", false)
if (blockWeight <= 0 && !disableVipDmColorful) return
instance.dmMossClass?.hookBeforeMethod(
"dmSegMobile",
"com.bapis.bilibili.community.service.dm.v1.DmSegMobileReq",
instance.mossResponseHandlerClass
) { param ->
param.args[1] = param.args[1].mossResponseHandlerProxy {
if (blockWeight > 0) filterDanmaku(it, blockWeight)
if (disableVipDmColorful) clearVipColorfulSrc(it)
}
}
}
private fun filterDanmaku(reply: Any?, blockWeight: Int) {
reply?.callMethodAs<List<Any>>("getElemsList")?.filter {
it.callMethodAs<Int>("getWeight") >= blockWeight
}?.let {
reply.callMethod("clearElems")
reply.callMethod("addAllElems", it)
}
}
private fun clearVipColorfulSrc(reply: Any?) {
reply?.callMethodOrNullAs<List<Any>>("getColorfulSrcList")?.filter {
// DmColorfulType 60001 VipGradualColor
it.callMethodAs<Int>("getTypeValue") != 60001
}?.let {
reply.callMethod("clearColorfulSrc")
reply.callMethod("addAllColorfulSrc", it)
}
}
}
@@ -0,0 +1,17 @@
package me.iacn.biliroaming.hook
import android.app.Dialog
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.blurBackground
import me.iacn.biliroaming.utils.hookAfterMethod
import me.iacn.biliroaming.utils.sPrefs
class DialogBlurBackgroundHook(mClassLoader: ClassLoader) : BaseHook(mClassLoader) {
override fun startHook() {
if (sPrefs.getBoolean("dialog_blur_background", false).not()) return
Log.d("startHook: DialogBlurBackgroundHook")
Dialog::class.java.hookAfterMethod("onStart") {
(it.thisObject as Dialog).window?.blurBackground()
}
}
}
@@ -0,0 +1,67 @@
package me.iacn.biliroaming.hook
import android.app.AlertDialog
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.NumberPicker
import android.widget.TextView
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
class DownloadThreadHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("custom_download_thread", false)) return
Log.d("startHook: DownloadThread")
instance.downloadThreadListenerClass?.run {
hookBeforeAllConstructors { param ->
val view = param.args.find { it is TextView } as? TextView
?: return@hookBeforeAllConstructors
val visibility = if (view.tag as Int == 1) {
view.text = "自定义"
View.VISIBLE
} else {
View.INVISIBLE
}
(view.parent as ViewGroup).getChildAt(1).visibility = visibility
}
replaceMethod("onClick", View::class.java) { param ->
var textViewField: String? = null
var viewHostField: String? = null
declaredFields.forEach {
when (it.type) {
instance.downloadThreadViewHostClass -> viewHostField = it.name
TextView::class.java -> textViewField = it.name
}
}
val view = param.thisObject.getObjectFieldAs<TextView>(textViewField)
if (view.tag as? Int == 1) {
AlertDialog.Builder(view.context).create().run {
setTitle("自定义同时缓存数")
val numberPicker = NumberPicker(context).apply {
minValue = 1
maxValue = 64
wrapSelectorWheel = false
value = param.thisObject.getObjectField(viewHostField)
?.getIntField(instance.downloadingThread())
?: 1
}
setView(numberPicker, 50, 0, 50, 0)
setButton(AlertDialog.BUTTON_POSITIVE, "OK") { _, _ ->
view.tag = numberPicker.value
param.invokeOriginalMethod()
}
show()
}
} else {
param.invokeOriginalMethod()
}
}
}
instance.reportDownloadThreadClass?.replaceMethod(
instance.reportDownloadThread(),
Context::class.java,
Int::class.javaPrimitiveType
) {}
}
}
@@ -0,0 +1,96 @@
package me.iacn.biliroaming.hook
import android.app.Activity
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
class DrawerHook(classLoader: ClassLoader) : BaseHook(classLoader) {
private var drawerLayout: Any? = null
private var navView: View? = null
override fun startHook() {
if (!sPrefs.getBoolean("drawer", false)) return
Log.d("startHook: DrawerHook")
runCatching {
instance.kanbanCallbackClass?.new(null)?.callMethod(instance.kanbanCallback(), null)
}
instance.mainActivityClass?.hookAfterMethod("onCreate", Bundle::class.java) { param ->
val self = param.thisObject as Activity
val view = self.findViewById<ViewGroup>(android.R.id.content).getChildAt(0)
(view.parent as ViewGroup).removeViewInLayout(view)
drawerLayout = instance.drawerLayoutClass?.new(self)
drawerLayout?.callMethod("addView", view, 0, view.layoutParams)
val homeFragment = instance.homeUserCenterClass?.new()
val fragmentManager = self.callMethod("getSupportFragmentManager")
fragmentManager?.callMethod("beginTransaction")?.callMethod("add", homeFragment, "home")
?.callMethod("commit")
fragmentManager?.callMethod("executePendingTransactions")
self.setContentView(drawerLayout as View)
}
val createHooker: Hooker = { param ->
val self = param.thisObject as Activity
val fragmentManager = self.callMethod("getSupportFragmentManager")
navView = fragmentManager?.callMethod("findFragmentByTag", "home")
?.callMethodAs<View>("getView")
val layoutParams = instance.drawerLayoutParamsClass?.new(
ViewGroup.MarginLayoutParams(
ViewGroup.MarginLayoutParams.MATCH_PARENT,
ViewGroup.MarginLayoutParams.MATCH_PARENT
)
)
layoutParams?.javaClass?.fields?.get(0)?.set(layoutParams, Gravity.START)
navView?.parent ?: drawerLayout?.callMethod("addView", navView, 1, layoutParams)
}
instance.mainActivityClass?.runCatching {
getDeclaredMethod(
"onPostCreate",
Bundle::class.java
)
}?.onSuccess { it.hookAfterMethod(createHooker) }
instance.mainActivityClass?.runCatching { getDeclaredMethod("onStart") }
?.onSuccess { it.hookAfterMethod(createHooker) }
instance.mainActivityClass?.replaceMethod("onBackPressed") { param ->
try {
if (drawerLayout?.callMethodAs<Boolean>(instance.isDrawerOpen(), navView) == true) {
drawerLayout?.callMethod(instance.closeDrawer(), navView, true)
} else {
param.invokeOriginalMethod()
}
} catch (e: Throwable) {
param.invokeOriginalMethod()
}
}
"tv.danmaku.bili.ui.main2.basic.BaseMainFrameFragment".hookAfterMethod(
mClassLoader,
"onViewCreated",
View::class.java,
Bundle::class.java
) { param ->
val id = getId("avatar_layout")
(param.args[0] as View).findViewById<View>(id)?.setOnClickListener {
try {
drawerLayout?.callMethod(instance.openDrawer(), navView, true)
} catch (e: Throwable) {
Log.e(e)
}
}
}
}
}
@@ -0,0 +1,221 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
class DynamicHook(classLoader: ClassLoader) : BaseHook(classLoader) {
private val dynamicMossV1 by Weak { "com.bapis.bilibili.app.dynamic.v1.DynamicMoss" from mClassLoader }
private val dynamicMossV2 by Weak { "com.bapis.bilibili.app.dynamic.v2.DynamicMoss" from mClassLoader }
private val purifyTypes = run {
sPrefs.getStringSet("customize_dynamic_type", null)
?.map { it.toInt() }.orEmpty()
}
private val purifyContents = run {
sPrefs.getStringSet("customize_dynamic_keyword_content", null).orEmpty()
}
private val purifyContentRegexes by lazy { purifyContents.map { it.toRegex() } }
private val contentRegexMode = sPrefs.getBoolean("dynamic_content_regex_mode", false)
private val purifyUpNames = run {
sPrefs.getStringSet("customize_dynamic_keyword_upname", null).orEmpty()
}
private val purifyUidList = run {
sPrefs.getStringSet("customize_dynamic_keyword_uid", null)
?.mapNotNull { it.toLongOrNull() }.orEmpty()
}
private val removeTopicOfAll = sPrefs.getBoolean("customize_dynamic_all_rm_topic", false)
private val removeUpOfAll = sPrefs.getBoolean("customize_dynamic_all_rm_up", false)
private val removeLiveOfAll = sPrefs.getBoolean("customize_dynamic_all_rm_live", false)
private val removeUpOfVideo = sPrefs.getBoolean("customize_dynamic_video_rm_up", false)
private val preferVideoTab = sPrefs.getBoolean("prefer_video_tab", false)
private val filterApplyToVideo = sPrefs.getBoolean("filter_apply_to_video", false)
private val rmBlocked = sPrefs.getBoolean("customize_dynamic_rm_blocked", false)
private val rmAdLink = sPrefs.getBoolean("customize_dynamic_rm_ad_link", false)
private val needFilterDynamic = purifyTypes.isNotEmpty() || purifyContents.isNotEmpty()
|| purifyUpNames.isNotEmpty() || purifyUidList.isNotEmpty() || rmBlocked
override fun startHook() {
val hidden = sPrefs.getBoolean("hidden", false)
if (hidden && (needFilterDynamic || removeTopicOfAll || removeUpOfAll || removeLiveOfAll)) {
dynamicMossV2?.hookBeforeMethod(
"dynAll",
"com.bapis.bilibili.app.dynamic.v2.DynAllReq",
instance.mossResponseHandlerClass
) { param ->
param.args[1] = param.args[1].mossResponseHandlerProxy { reply ->
reply ?: return@mossResponseHandlerProxy
Log.d("upList: ${reply.callMethod("getUpList")}")
if (removeTopicOfAll)
reply.callMethod("clearTopicList")
if (removeUpOfAll || removeLiveOfAll) {
reply.callMethod("getUpList")?.runCatching UpList@{
val upList = this@UpList
val firstList =
upList.callMethodAs("getListList") ?: emptyList<Any>()
val secondList =
upList.callMethodAs("getListSecondList") ?: emptyList<Any>()
val allUpItems = firstList + secondList
val newItems = allUpItems.filter { item ->
val liveState = item.callMethodAs<Int>("getLiveStateValue")
val isLive = liveState > 0
when {
removeUpOfAll && removeLiveOfAll -> false
removeUpOfAll -> isLive
else -> !isLive
}
}.onEachIndexed { index, item ->
item.callMethod("setPos", index + 1L)
}
upList.callMethod("clearList")
upList.callMethod("clearListSecond")
upList.callMethod("addAllList", newItems)
}?.onFailure { Log.e(it) }
}
if (needFilterDynamic)
filterDynamic(reply)
}
}
}
if (hidden && ((filterApplyToVideo && needFilterDynamic) || removeUpOfVideo)) {
dynamicMossV2?.hookBeforeMethod(
"dynVideo",
"com.bapis.bilibili.app.dynamic.v2.DynVideoReq",
instance.mossResponseHandlerClass
) {
it.args[1] = it.args[1].mossResponseHandlerProxy { result ->
result?.let {
if (removeUpOfVideo)
it.callMethod("clearVideoUpList")
if (filterApplyToVideo && needFilterDynamic)
filterDynamic(it)
}
}
}
}
if (hidden && preferVideoTab) {
dynamicMossV2?.hookBeforeMethod(
"dynTab",
"com.bapis.bilibili.app.dynamic.v2.DynTabReq",
instance.mossResponseHandlerClass
) {
it.args[1] = it.args[1].mossResponseHandlerReplaceProxy { reply ->
reply?.callMethod("ensureScreenTabIsMutable")
reply?.callMethodAs<MutableList<Any>>("getScreenTabList")?.map {
it.callMethod(
"setDefaultTab",
it.callMethodAs<String>("getName") == "video"
)
}
reply
}
}
dynamicMossV1?.hookAfterMethod(
if (instance.useNewMossFunc) "executeDynRed" else "dynRed",
"com.bapis.bilibili.app.dynamic.v1.DynRedReq"
) { param ->
param.result?.callMethod("setDefaultTab", "video")
}
}
}
private fun filterDynamic(reply: Any) {
val dynamicList = reply.callMethod("getDynamicList") ?: return
dynamicList.callMethod("ensureListIsMutable")
val contentList = dynamicList.callMethodAs<MutableList<Any>>("getListList")
val idxList = mutableSetOf<Int>()
for ((idx, e) in contentList.withIndex()) {
if (purifyTypes.isNotEmpty()) {
val cardType = e.callMethodAs<Int>("getCardTypeValue")
if (purifyTypes.contains(cardType)) {
idxList.add(idx)
continue
}
}
val extend = e.callMethod("getExtend") ?: continue
if (rmBlocked
&& extend.callMethodOrNull("hasOnlyFansProperty") == true
&& extend.callMethod("getOnlyFansProperty")?.callMethod("getHasPrivilege") == false
) {
idxList.add(idx)
continue
}
if (rmAdLink || purifyContents.isNotEmpty()) {
val moduleList = e.callMethodAs<List<Any>>("getModulesList")
if (rmAdLink && moduleList.any {
it.callMethodOrNull("hasModuleAdditional") == true
&& it.callMethod("getModuleAdditional")
?.callMethod("getTypeValue").let {
it == 2 || it == 6
} // additional_type_goods, additional_type_up_rcmd
}) {
idxList.add(idx)
continue
}
val modulesText = moduleList.joinToString(separator = "") {
it.callMethod("getModuleDesc")
?.callMethodAs<String>("getText").orEmpty()
}
if (modulesText.isNotEmpty() && if (contentRegexMode)
purifyContentRegexes.any { modulesText.contains(it) }
else purifyContents.any { modulesText.contains(it) }
) {
idxList.add(idx)
continue
}
}
if (purifyContents.isNotEmpty()) {
val contentOrig = extend
.callMethodAs<List<Any>>("getOrigDescList")
.joinToString(separator = "") {
it.callMethodAs<String>("getOrigText")
}
if (contentOrig.isNotEmpty() && if (contentRegexMode)
purifyContentRegexes.any { contentOrig.contains(it) }
else purifyContents.any { contentOrig.contains(it) }
) {
idxList.add(idx)
continue
}
}
if (purifyContents.isNotEmpty()) {
val content = extend
.callMethodAs<List<Any>>("getDescList")
.joinToString(separator = "") {
it.callMethodAs<String>("getOrigText")
}
if (content.isNotEmpty() && if (contentRegexMode)
purifyContentRegexes.any { content.contains(it) }
else purifyContents.any { content.contains(it) }
) {
idxList.add(idx)
continue
}
}
if (purifyUpNames.isNotEmpty()) {
val origName = extend.callMethodAs<String>("getOrigName")
if (origName.isNotEmpty() && purifyUpNames.any { origName == it }) {
idxList.add(idx)
continue
}
}
if (purifyUidList.isNotEmpty()) {
val uid = extend.callMethodAs<Long>("getUid")
if (uid > 0L && purifyUidList.any { uid == it }) {
idxList.add(idx)
continue
}
}
}
idxList.reversed().forEach {
contentList.removeAt(it)
}
}
}
@@ -0,0 +1,136 @@
package me.iacn.biliroaming.hook
import android.content.SharedPreferences
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
import java.lang.reflect.Proxy
import java.util.regex.Pattern
class EnvHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
Log.d("startHook: Env")
// EnvContext
instance.preBuiltConfigClass?.let {
val hooker: Hooker = hooker@ { param ->
@Suppress("UNCHECKED_CAST")
val result = param.result as? MutableMap<String, String?> ?: return@hooker
for (config in configSet) {
config.getEncryptedValue()?.let { result[config.key] = it }
?: result.remove(config.key)
}
}
// v8.28.0 - ?
runCatching { it.hookAfterMethod(instance.getPreBuiltConfigMethod(), hooker = hooker) }
// ? - v8.48.0 ..
runCatching { it.hookAfterMethod(instance.getPreBuiltConfigMethod(), it, hooker = hooker) }
}
// TypedContext
instance.dataSPClass?.let {
val hooker: Hooker = hooker@ { param ->
val result = param.result as? SharedPreferences ?: return@hooker
if (!result.contains("bv.enable_bv")) return@hooker
for (config in configSet) {
config.getEncryptedValue()?.let {
result.edit().putString(config.key, it).apply()
} ?: result.edit().remove(config.key).apply()
}
}
// v8.28.0 - ?
runCatching { it.hookAfterMethod(instance.getDataSPMethod(), hooker = hooker) }
// ? - v8.48.0 ..
runCatching { it.hookAfterMethod(instance.getDataSPMethod(), it, hooker = hooker) }
}
"com.bilibili.lib.blconfig.internal.OverrideConfig".findClassOrNull(mClassLoader)
?.hookBeforeAllConstructors { param ->
val delegate = param.args.getOrNull(0) ?: return@hookBeforeAllConstructors
val realConfig = param.args.getOrNull(1) // may be null on 8.97.0+ (z12=true)
val delegateClass = delegate.javaClass
param.args[0] = Proxy.newProxyInstance(
delegateClass.classLoader,
delegateClass.interfaces
) { _, m, a ->
val args = a ?: emptyArray()
if (m.name == "getConfig") {
var result: Any? = null
val key = args[0]
for (config in configSet) {
if (config.key == key) {
result = if (realConfig != null) {
realConfig.callMethodOrNull("get", *args)
} else config.getPlainValue()
}
}
result ?: m(delegate, *args)
} else {
m(delegate, *args)
}
}
}
// // Disable tinker
// "com.tencent.tinker.loader.app.TinkerApplication".findClass(mClassLoader)?.hookBeforeAllConstructors { param ->
// param.args[0] = 0
// }
}
override fun lateInitHook() {
Log.d("lateHook: Env")
if (sPrefs.getBoolean("enable_av", false)) {
val compatClass = "com.bilibili.droid.BVCompat".findClassOrNull(mClassLoader)
compatClass?.declaredFields?.forEach { f ->
runCatchingOrNull {
val field = compatClass.getStaticObjectField(f.name)
if (field is Pattern && field.pattern() == "av[1-9]\\d*") {
compatClass.setStaticObjectField(
f.name,
Pattern.compile("(av[1-9]\\d*)|(BV1[1-9A-NP-Za-km-z]{9})", field.flags())
)
}
}
if (f.type == Boolean::class.javaPrimitiveType) {
runCatching {
f.isAccessible = true
f.setBoolean(null, false)
}.onFailure { Log.e(it) }
}
}
}
}
companion object {
private val encryptedValueMap = hashMapOf(
"0" to "Irb5O7Q8Ka0ojD4qqScgqg==",
"1" to "Y260Cyvp6HZEboaGO+YGMw=="
)
class ConfigTuple(
val key: String,
val config: String,
val trueValue: String?,
val falseValue: String?,
val plainTrueValue: String? = null,
val plainFalseValue: String? = null
) {
fun getEncryptedValue(): String? =
if (sPrefs.getBoolean(config, false)) trueValue else falseValue
fun getPlainValue(): String? =
if (sPrefs.getBoolean(config, false)) plainTrueValue else plainFalseValue
}
val configSet = listOf(
ConfigTuple(
"bv.enable_bv",
"enable_av",
encryptedValueMap["0"],
encryptedValueMap["1"],
"0",
"1"
),
)
}
}
@@ -0,0 +1,14 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.replaceMethod
import me.iacn.biliroaming.utils.sPrefs
class FullStoryHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("disable_story_full", false)) return
instance.playerFullStoryWidgets().forEach { (clazz, method) ->
clazz?.replaceMethod(method, clazz) { false }
}
}
}
@@ -0,0 +1,52 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.hook.gson.GsonProcessor
import me.iacn.biliroaming.hook.gson.SplashListProcessor
import me.iacn.biliroaming.hook.gson.SplashShowProcessor
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.getObjectField
import me.iacn.biliroaming.utils.hookAfterAllMethods
class GsonHook(classLoader: ClassLoader) : BaseHook(classLoader) {
private val allProcessors = listOf(
SplashListProcessor(),
SplashShowProcessor(),
)
private val enabledProcessors: Map<String, List<GsonProcessor>> by lazy {
allProcessors
.filter { it.shouldEnable() }
.groupBy { it.targetClassName }
}
override fun startHook() {
if (enabledProcessors.isEmpty()) return
Log.d("startHook: Gson")
val gsonClass = instance.gsonClass ?: return
val fromJsonName = instance.gsonFromJson() ?: return
gsonClass.hookAfterAllMethods(fromJsonName) { param ->
dispatchResult(param.result)
}
}
private fun dispatchResult(result: Any?) {
if (result == null) return
// Network path: unwrap GeneralResponse<T> -> T
var target = result
if (result.javaClass == instance.generalResponseClass) {
target = result.getObjectField("data") ?: return
}
enabledProcessors[target.javaClass.name]?.forEach { processor ->
try {
processor.process(target)
} catch (e: Throwable) {
Log.e("GsonHook processor ${processor.targetClassName} error: $e")
}
}
}
}
@@ -0,0 +1,28 @@
package me.iacn.biliroaming.hook
import android.app.Activity
import android.app.AlertDialog
import android.os.Bundle
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.R
import me.iacn.biliroaming.utils.addModuleAssets
import me.iacn.biliroaming.utils.hookAfterMethod
import me.iacn.biliroaming.utils.inflateLayout
import me.iacn.biliroaming.utils.sPrefs
class HintHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("show_hint", true)) return
instance.mainActivityClass?.hookAfterMethod("onCreate", Bundle::class.java) { param ->
AlertDialog.Builder(param.thisObject as Activity).run {
context.addModuleAssets()
setTitle("哔哩漫游使用说明")
setView(context.inflateLayout(R.layout.feature))
setNegativeButton("知道了") { _, _ ->
sPrefs.edit().putBoolean("show_hint", false).apply()
}
show()
}
}
}
}
@@ -0,0 +1,605 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
import java.lang.reflect.Type
class JsonHook(classLoader: ClassLoader) : BaseHook(classLoader) {
companion object {
val bottomItems = mutableListOf<BottomItem>()
val drawerItems = mutableListOf<BottomItem>()
}
override fun startHook() {
Log.d("startHook: Json")
val hidden = sPrefs.getBoolean("hidden", false)
val purifyLivePopups = sPrefs.getStringSet("purify_live_popups", null) ?: setOf()
val unlockPlayLimit = sPrefs.getBoolean("play_arc_conf", false)
val tabResponseClass =
"tv.danmaku.bili.ui.main2.resource.MainResourceManager\$TabResponse".findClassOrNull(
mClassLoader
)
val accountMineClass =
"tv.danmaku.bili.ui.main2.api.AccountMine".findClassOrNull(mClassLoader)
val garbEntranceClass =
"tv.danmaku.bili.ui.main2.api.AccountMine\$GarbEntrance".from(mClassLoader)
val splashClass = "tv.danmaku.bili.ui.splash.SplashData".findClassOrNull(mClassLoader)
?: "tv.danmaku.bili.ui.splash.ad.model.SplashData".findClassOrNull(mClassLoader)
val splashShowClass = "tv.danmaku.bili.ui.splash.ad.model.SplashShowData".findClassOrNull(mClassLoader)
val tabClass =
"tv.danmaku.bili.ui.main2.resource.MainResourceManager\$Tab".findClassOrNull(
mClassLoader
)
val defaultWordClass =
"tv.danmaku.bili.ui.main2.api.SearchDefaultWord".findClassOrNull(mClassLoader)
val defaultKeywordClass =
"com.bilibili.search.api.DefaultKeyword".findClassOrNull(mClassLoader)
val brandSplashDataClass =
"tv.danmaku.bili.ui.splash.brand.BrandSplashData".findClassOrNull(mClassLoader)
?: "tv.danmaku.bili.ui.splash.brand.model.BrandSplashData".findClassOrNull(mClassLoader)
val eventEntranceClass =
"tv.danmaku.bili.ui.main.event.model.EventEntranceModel".findClassOrNull(mClassLoader)
val searchRanksClass = "com.bilibili.search.api.SearchRanks".findClassOrNull(mClassLoader)
val searchReferralClass =
"com.bilibili.search.api.SearchReferral".findClassOrNull(mClassLoader)
val searchReferralV2Class =
"com.bilibili.search2.api.SearchReferral".findClassOrNull(mClassLoader)
val followingcardSearchRanksClass =
"com.bilibili.bplus.followingcard.net.entity.b".findClassOrNull(mClassLoader)
val spaceClass =
"com.bilibili.app.authorspace.api.BiliSpace".findClassOrNull(mClassLoader)
val ogvApiResponseClass =
"tv.danmaku.bili.ui.offline.api.OgvApiResponse".findClassOrNull(mClassLoader)
val ogvApiResponseV2Class =
"tv.danmaku.bili.ui.offline.api.OgvApiResponseV2".findClassOrNull(mClassLoader)
val dmAdvertClass =
"com.bilibili.ad.adview.videodetail.danmakuv2.model.DmAdvert".from(mClassLoader)
val liveShoppingInfoClass =
"com.bilibili.bililive.room.biz.shopping.beans.LiveShoppingInfo".from(mClassLoader)
val liveGoodsCardInfoClass =
"com.bilibili.bililive.room.biz.shopping.beans.LiveGoodsCardInfo".from(mClassLoader)
val liveRecommendCardGoodsClass =
"com.bilibili.bililive.room.biz.shopping.beans.LiveShoppingRecommendCardGoodsDetail"
.from(mClassLoader)
val biliLiveRoomInfoClass =
"com.bilibili.bililive.videoliveplayer.net.beans.gateway.roominfo.BiliLiveRoomInfo"
.from(mClassLoader)
val liveRoomReserveInfoClass =
"com.bilibili.bililive.room.biz.reverse.bean.LiveRoomReserveInfo".from(mClassLoader)
val biliLiveRoomUserInfoClass =
"com.bilibili.bililive.videoliveplayer.net.beans.gateway.userinfo.BiliLiveRoomUserInfo"
.from(mClassLoader)
val liveRoomRecommendCardClass =
"com.bilibili.bililive.videoliveplayer.net.beans.attentioncard.LiveRoomRecommendCard"
.from(mClassLoader)
val liveShoppingGotoBuyInfoClass =
"com.bilibili.bililive.room.biz.shopping.beans.LiveShoppingGotoBuyInfo"
.from(mClassLoader)
val shareChannelsClass = "com.bilibili.lib.sharewrapper.online.api.ShareChannels"
.from(mClassLoader)
val channelItemClass = "com.bilibili.lib.sharewrapper.online.api.ShareChannels\$ChannelItem"
.from(mClassLoader)
instance.fastJsonClass?.hookAfterMethod(
instance.fastJsonParse(),
String::class.java,
Type::class.java,
Int::class.javaPrimitiveType,
"com.alibaba.fastjson.parser.Feature[]"
) { param ->
var result = param.result ?: return@hookAfterMethod
if (result.javaClass == instance.generalResponseClass) {
result = result.getObjectField("data") ?: return@hookAfterMethod
}
when (result.javaClass) {
tabResponseClass -> {
val data = result.getObjectField("tabData")
bottomItems.clear()
val hides = sPrefs.getStringSet("hided_bottom_items", mutableSetOf())!!
data?.getObjectFieldAs<MutableList<*>?>("bottom")?.removeAll {
val uri = it?.getObjectFieldAs<String>("uri")
val id = it?.getObjectFieldAs<String>("tabId")
val showing = id !in hides
bottomItems.add(
BottomItem(
it?.getObjectFieldAs("name"),
uri, id, showing
)
)
showing.not()
}
if (sPrefs.getBoolean("drawer", false) && !sPrefs.getBoolean("hidden", false)) {
data?.getObjectFieldAs<MutableList<*>?>("bottom")?.removeAll {
it?.getObjectFieldAs<String?>("uri")
?.startsWith("bilibili://user_center/mine")
?: false
}
}
configTab(data, tabClass)
if (sPrefs.getBoolean("purify_game", false) &&
sPrefs.getBoolean("hidden", false)
) {
val top = data?.getObjectFieldAs<MutableList<*>?>("top")
top?.removeAll {
val uri = it?.getObjectFieldAs<String?>("uri")
uri?.startsWith("bilibili://game_center/home") ?: false
}
}
}
accountMineClass -> {
drawerItems.clear()
val hides = sPrefs.getStringSet("hided_drawer_items", mutableSetOf())!!
if (platform == "android_hd") {
listOf(result.getObjectFieldOrNullAs<MutableList<*>?>("padSectionList"),
result.getObjectFieldOrNullAs<MutableList<*>?>("recommendSectionList"),
result.getObjectFieldOrNullAs<MutableList<*>?>("moreSectionList")
).forEach {
it?.removeAll { items ->
// 分析内容
val title = items?.getObjectFieldAs<String>("title")
val uri = items?.getObjectFieldAs<String>("uri")
val id = items?.getObjectField("id").toString()
// 修改成自定义按钮
if (sPrefs.getBoolean("add_custom_button", false) && id == sPrefs.getString("custom_button_id", "")){
val icon = items?.getObjectFieldAs<String>("icon").toString()
items?.setObjectField("title", sPrefs.getString("custom_button_title", title))
?.setObjectField("uri", sPrefs.getString("custom_button_uri", uri))
?.setObjectField("icon", sPrefs.getString("custom_button_icon", icon))
return@removeAll false
}
val showing = id !in hides
// 将结果写入 drawerItems
drawerItems.add(BottomItem(title, uri, id, showing))
// 去除红点
if (sPrefs.getBoolean("purify_drawer_reddot", false)) items?.setIntField("redDot",0)
showing.not()
}
}
} else {
result.getObjectFieldOrNullAs<MutableList<*>?>("sectionListV2")?.forEach { sections ->
try {
// 将标题写入 drawerItems
val bigTitle = sections?.getObjectFieldOrNull("title").toString()
if (bigTitle != "null") drawerItems.add(BottomItem("【标题项目】", null, bigTitle, bigTitle !in hides))
// 去除项目
sections?.getObjectFieldOrNullAs<MutableList<*>?>("itemList")
?.removeAll { items ->
// 分析内容
val title = try {
items?.getObjectFieldAs<String>("title")
} catch (thr: Throwable) {
return@removeAll false
}
if (title == "null") return@removeAll false
val uri = items?.getObjectFieldAs<String>("uri")
val id = items?.getObjectField("id").toString()
// 修改成自定义按钮
if (sPrefs.getBoolean("add_custom_button", false) && id == sPrefs.getString("custom_button_id", "")){
val icon = items?.getObjectFieldAs<String>("icon").toString()
items?.setObjectField("title", sPrefs.getString("custom_button_title", title))
?.setObjectField("uri", sPrefs.getString("custom_button_uri", uri))
?.setObjectField("icon", sPrefs.getString("custom_button_icon", icon))
return@removeAll false
}
val showing = id !in hides
// 将结果写入 drawerItems
drawerItems.add(BottomItem(title, uri, id, showing))
// 去除红点
if (sPrefs.getBoolean("purify_drawer_reddot", false)) items?.setIntField("redDot", 0)
showing.not()
}
// 去除按钮
val button = sections?.getObjectFieldOrNull("button")
if (button != null) {
val buttonText = button.getObjectField("text").toString()
val showing = buttonText !in hides
if (buttonText != "null") {
val uri = button.getObjectFieldAs<String>("jumpUrl")
drawerItems.add(BottomItem("按钮:", uri, buttonText, showing))
if (!showing) sections.setObjectField("button", null)
}
}
// 改变样式
if (sPrefs.getBoolean("drawer_style_switch", false)) {
sections?.setIntField(
"style",
when {
sPrefs.getBoolean("drawer_style", false) -> 2
else -> 1
}
)
}
} catch (e: Exception) {
Log.d(e)
}
}
// 删除标题组
var deleteTitle = true
result.getObjectFieldOrNullAs<MutableList<*>?>("sectionListV2")?.removeAll { sections ->
val title = sections?.getObjectFieldOrNull("title").toString()
if (title !in hides) {
if (title == "创作中心" || title == "游戏中心" || title == "創作中心" || title == "遊戲中心")
deleteTitle = false // 标记
else if (title == "推荐服务" || title == "推薦服務")
deleteTitle = true // 取消标记
} else {
if ((title == "更多服务" || title == "更多服務") && !deleteTitle) {
Log.toast("自定义我的页面,【标题项目】不能只保留【创作中心】或【游戏中心】,因此不删除【更多服务】,请修改你的漫游设置", true)
return@removeAll false
}
}
title in hides
}
// liveTip
// 就是通常在 我的 -> 创做中心 下 但不属于创作中心的提示
result.getObjectFieldOrNull("liveTip")?.let { liveTip ->
val text = liveTip.getObjectFieldAs<String>("text")
val id = liveTip.getObjectField("id").toString()
val url = liveTip.getObjectFieldAs<String>("url")
val showing = id !in hides
drawerItems.add(BottomItem(
name = text, id = id, uri = url, showing = showing
))
if (!showing) {
result.setObjectField("liveTip", null)
}
}
}
accountMineClass.findFieldOrNull("vipSectionRight")?.set(result, null)
if (sPrefs.getBoolean("custom_theme", false)) {
if (instance.clientVersionCode >= 7480200) {
garbEntranceClass?.new()?.apply {
setObjectField("uri", "activity://navigation/theme/")
}?.let { result.setObjectField("garbEntrance", it) }
} else {
result.setObjectField("garbEntrance", null)
}
}
}
splashClass, splashShowClass -> if (sPrefs.getBoolean("purify_splash", false) &&
sPrefs.getBoolean("hidden", false)
) {
result.getObjectFieldOrNullAs<MutableList<*>>("splashList")?.clear()
result.getObjectFieldOrNullAs<MutableList<*>>("strategyList")?.clear()
}
defaultWordClass, defaultKeywordClass, searchRanksClass, searchReferralClass, followingcardSearchRanksClass -> if (sPrefs.getBoolean(
"purify_search",
false
) &&
sPrefs.getBoolean("hidden", false)
) {
result.javaClass.fields.forEach {
if (!it.type.isPrimitive)
result.setObjectField(it.name, null)
}
}
searchReferralV2Class -> if (sPrefs.getBoolean(
"purify_search",
false
) &&
sPrefs.getBoolean("hidden", false)
) {
result.javaClass.declaredFields.forEach {
it.isAccessible = true
it.set(result, it.type.let { type ->
when (type) {
Int::class.javaPrimitiveType -> 0
else -> null
}
})
}
}
brandSplashDataClass -> if (sPrefs.getBoolean("custom_splash", false) ||
sPrefs.getBoolean("custom_splash_logo", false)
) {
result.getObjectFieldOrNullAs<MutableList<Any>>("brandList")?.clear()
result.getObjectFieldOrNullAs<MutableList<Any>>("showList")?.clear()
}
eventEntranceClass -> if (sPrefs.getBoolean("purify_game", false) &&
sPrefs.getBoolean("hidden", false)
) {
result.setObjectField("online", null)
result.setObjectField("hash", "")
}
spaceClass -> {
val purifySpaceSet =
sPrefs.getStringSet("customize_space", emptySet()).orEmpty()
if (purifySpaceSet.isNotEmpty()) {
purifySpaceSet.forEach {
if (!it.contains(".")) result.setObjectField(
it,
null
)
}
// Exceptions (adV2 -> ad + adV2)
if (purifySpaceSet.contains("adV2")) result.setObjectField("ad", null)
result.getObjectFieldAs<MutableList<*>?>("tab")?.removeAll {
when (it?.getObjectFieldAs<String?>("param")) {
"home" -> purifySpaceSet.contains("tab.home")
"dynamic" -> purifySpaceSet.contains("tab.dynamic")
"contribute" -> purifySpaceSet.contains("tab.contribute")
"shop" -> purifySpaceSet.contains("tab.shop")
"bangumi" -> purifySpaceSet.contains("tab.bangumi")
"cheese" -> purifySpaceSet.contains("tab.cheese")
else -> false
}
}
}
}
ogvApiResponseClass -> if (sPrefs.getBoolean("allow_download", false)) {
result.getObjectFieldOrNullAs<ArrayList<*>>("result")?.forEach {
it?.setIntField("isPlayable", 1)
}
}
ogvApiResponseV2Class -> if (sPrefs.getBoolean("allow_download", false)) {
result.getObjectFieldOrNull("data")
?.getObjectFieldOrNullAs<MutableList<*>>("epPlayableParams")
?.forEach { it?.setIntField("playableType", 0) }
}
dmAdvertClass -> if (hidden && sPrefs.getBoolean("block_up_rcmd_ads", false))
result.setObjectField("ads", null)
shareChannelsClass -> if (unlockPlayLimit) runCatchingOrNull {
val belowChannels = result.getObjectFieldAs<ArrayList<Any>?>("belowChannels")
.takeUnless { it.isNullOrEmpty() } ?: return@hookAfterMethod
var alreadyHas = false
var toInsertIdx = -1
belowChannels.forEachIndexed { idx, item ->
val shareChannel = item.getObjectFieldAs<String?>("shareChannel")
if (shareChannel == "PLAY_BACKGROUND_OFF")
toInsertIdx = idx
if (shareChannel == "LISTEN")
alreadyHas = true
}
if (alreadyHas || toInsertIdx == -1)
return@hookAfterMethod
val listenChannel = channelItemClass?.new()
?.setObjectField("name", "听视频")
?.setObjectField("shareChannel", "LISTEN")
?.setObjectField(
"picture",
"https://i0.hdslb.com/bfs/share/f88d8c420a59ff1ca5975b38722408056e7337b7.png"
) ?: return@hookAfterMethod
belowChannels.add(toInsertIdx, listenChannel)
}
}
}
val searchRankClass = "com.bilibili.search.api.SearchRank".findClassOrNull(mClassLoader)
val searchGuessClass =
"com.bilibili.search.api.SearchReferral\$Guess".findClassOrNull(mClassLoader)
val searchRankV2Class = "com.bilibili.search2.api.SearchRank".findClassOrNull(mClassLoader)
val searchGuessV2Class = "com.bilibili.search2.api.SearchReferral\$Guess".findClassOrNull(mClassLoader)
instance.fastJsonClass?.hookAfterMethod(
"parseArray",
String::class.java,
Class::class.java
) { param ->
@Suppress("UNCHECKED_CAST")
val result = param.result as? MutableList<Any>
when (param.args[1] as Class<*>) {
searchRankClass, searchGuessClass, searchRankV2Class, searchGuessV2Class ->
if (sPrefs.getBoolean("purify_search", false) && sPrefs.getBoolean(
"hidden",
false
)
) {
result?.clear()
}
}
}
instance.fastJsonClass?.hookAfterMethod(
instance.fastJsonParse(),
String::class.java,
Type::class.java,
"com.alibaba.fastjson.parser.Feature[]"
) { param ->
var result = param.result ?: return@hookAfterMethod
if (result.javaClass == instance.generalResponseClass)
result = result.getObjectField("data") ?: return@hookAfterMethod
when (result.javaClass) {
liveShoppingInfoClass -> {
if (hidden && purifyLivePopups.contains("shoppingCard")) {
result.setObjectField("shoppingCardDetail", null)
result.runCatchingOrNull {
setObjectField("recommendCardDetail", null)
}
}
if (hidden && purifyLivePopups.contains("shoppingSelected"))
result.runCatchingOrNull {
setObjectField("selectedGoods", null)
}
}
liveGoodsCardInfoClass, liveRecommendCardGoodsClass -> {
if (hidden && purifyLivePopups.contains("shoppingCard"))
param.result = null
}
biliLiveRoomInfoClass -> {
if (hidden && purifyLivePopups.contains("follow"))
result.getObjectFieldOrNull("functionCard")
?.setObjectField("followCard", null)
if (hidden && purifyLivePopups.contains("banner"))
result.setObjectField("bannerInfo", null)
if (hidden && sPrefs.getBoolean("no_live_mask", false))
result.setObjectField("areaMaskInfo", null)
}
liveRoomRecommendCardClass -> {
if (hidden && purifyLivePopups.contains("follow"))
param.result = null
}
liveRoomReserveInfoClass -> {
if (hidden && purifyLivePopups.contains("reserve"))
result.setBooleanField("showReserveDetail", false)
}
biliLiveRoomUserInfoClass -> {
if (hidden && purifyLivePopups.contains("gift"))
result.getObjectFieldOrNull("functionCard")
?.setObjectField("sengGiftCard", null)
if (hidden && purifyLivePopups.contains("task"))
result.runCatchingOrNull {
setObjectField("taskInfo", null)
}
}
liveShoppingGotoBuyInfoClass -> {
if (hidden && purifyLivePopups.contains("gotoBuy"))
param.result = null
}
}
}
}
private fun configTab(data: Any?, tabClass: Class<*>?) {
val tab = data?.getObjectFieldAs<MutableList<Any>>("tab") ?: return
if (tabClass == null) return
var hasBangumiCN = false
var hasBangumiTW = false
var hasMovieCN = false
var hasMovieTW = false
var hasKoreaHK = false
var hasKoreaTW = false
tab.forEach {
when (it.getObjectFieldAs<String>("uri")) {
"bilibili://pgc/bangumi_v2",
"bilibili://pgc/home" -> hasBangumiCN = true
"bilibili://following/home_activity_tab/6544" -> hasBangumiTW = true
"bilibili://pgc/cinema_v2",
"bilibili://pgc/home?home_flow_type=2" -> hasMovieCN = true
"bilibili://following/home_activity_tab/168644" -> hasMovieTW = true
"bilibili://following/home_activity_tab/163541" -> hasKoreaHK = true
"bilibili://following/home_activity_tab/95636" -> hasKoreaTW = true
}
}
if (sPrefs.getBoolean("add_bangumi", false)) {
if (!hasBangumiCN) {
val bangumiCN = tabClass.new()
.setObjectField("tabId", "50")
.setObjectField("name", "追番(大陸)")
.setObjectField("uri", "bilibili://pgc/home")
.setObjectField("reportId", "bangumi")
.setIntField("pos", 50)
tab.add(bangumiCN)
}
if (!hasBangumiTW) {
val bangumiTW = tabClass.new()
.setObjectField("tabId", "60")
.setObjectField("name", "追番(港澳台)")
.setObjectField("uri", "bilibili://following/home_activity_tab/6544")
.setObjectField("reportId", "bangumi")
.setIntField("pos", 60)
tab.add(bangumiTW)
}
}
if (sPrefs.getBoolean("add_movie", false)) {
if (!hasMovieCN) {
val movieCN = tabClass.new()
.setObjectField("tabId", "70")
.setObjectField("name", "影視(大陸)")
.setObjectField("uri", "bilibili://pgc/home?home_flow_type=2")
.setObjectField("reportId", "film")
.setIntField("pos", 70)
tab.add(movieCN)
}
if (!hasMovieTW) {
val movieTW = tabClass.new()
.setObjectField("tabId", "80")
.setObjectField("name", "戏剧(港澳台)")
.setObjectField("uri", "bilibili://following/home_activity_tab/168644")
.setObjectField("reportId", "jptv")
.setIntField("pos", 80)
tab.add(movieTW)
}
}
if (sPrefs.getBoolean("add_korea", false)) {
if (!hasKoreaHK) {
val koreaHK = tabClass.new()
.setObjectField("tabId", "803")
.setObjectField("name", "韩综(港澳)")
.setObjectField("uri", "bilibili://following/home_activity_tab/163541")
.setObjectField("reportId", "koreavhk")
.setIntField("pos", 803)
tab.add(koreaHK)
}
if (!hasKoreaTW) {
val koreaTW = tabClass.new()
.setObjectField("tabId", "804")
.setObjectField("name", "韩综(台湾)")
.setObjectField("uri", "bilibili://following/home_activity_tab/95636")
.setObjectField("reportId", "koreavtw")
.setIntField("pos", 804)
tab.add(koreaTW)
}
}
val purifytabset = sPrefs.getStringSet("customize_home_tab", emptySet())!!
if (purifytabset.isEmpty()) return
tab.removeAll {
when (it.getObjectFieldAs<String>("uri")) {
"bilibili://live/home"
-> purifytabset.contains("live")
"bilibili://pegasus/promo"
-> purifytabset.contains("promo")
"bilibili://pegasus/hottopic"
-> purifytabset.contains("hottopic")
"bilibili://pgc/bangumi_v2",
"bilibili://pgc/home",
"bilibili://following/home_activity_tab/6544"
-> purifytabset.contains("bangumi")
"bilibili://pgc/cinema_v2",
"bilibili://pgc/home?home_flow_type=2",
"bilibili://following/home_activity_tab/168644"
-> purifytabset.contains("movie")
"bilibili://following/home_activity_tab/95636",
"bilibili://following/home_activity_tab/163541"
-> purifytabset.contains("korea")
else -> purifytabset.contains("other_tabs")
}
}
}
data class BottomItem(
val name: String?,
val uri: String?,
val id: String?,
var showing: Boolean
)
}
@@ -0,0 +1,16 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.Constant
import me.iacn.biliroaming.utils.hookAfterMethod
import me.iacn.biliroaming.utils.packageName
class KillDelayBootHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
instance.gripperBootExpClass?.hookAfterMethod(
if (packageName == Constant.PLAY_PACKAGE_NAME) "b" else "getDelayMillis"
) { param ->
param.result = -1L
}
}
}
@@ -0,0 +1,51 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.hook.kotlinx.KotlinxProcessor
import me.iacn.biliroaming.hook.kotlinx.KotlinxSplashListProcessor
import me.iacn.biliroaming.hook.kotlinx.KotlinxSplashShowProcessor
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.callMethodOrNull
import me.iacn.biliroaming.utils.getObjectFieldAs
import me.iacn.biliroaming.utils.hookAfterAllMethods
class KotlinxJsonHook(classLoader: ClassLoader) : BaseHook(classLoader) {
private val allProcessors = listOf(
KotlinxSplashListProcessor(),
KotlinxSplashShowProcessor(),
)
private val enabledProcessors: Map<String, List<KotlinxProcessor>> by lazy {
allProcessors
.filter { it.shouldEnable() }
.groupBy { it.targetSerialName }
}
override fun startHook() {
if (enabledProcessors.isEmpty()) return
Log.d("startHook: KotlinxJson")
val jsonClass = instance.kotlinJsonClass ?: return
jsonClass.hookAfterAllMethods("decodeFromString") { param ->
dispatchResult(param.args.getOrNull(0), param.result)
}
}
private fun dispatchResult(deserializer: Any?, result: Any?) {
if (deserializer == null || result == null) return
val serialName = deserializer
.callMethodOrNull("getDescriptor")
?.getObjectFieldAs<String>("serialName")
?: return
enabledProcessors[serialName]?.forEach { processor ->
try {
processor.process(result)
} catch (e: Throwable) {
Log.e("KotlinxJsonHook processor ${processor.targetSerialName} error: $e")
}
}
}
}
@@ -0,0 +1,198 @@
package me.iacn.biliroaming.hook
import android.net.Uri
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.BuildConfig
import me.iacn.biliroaming.utils.*
import org.json.JSONArray
import org.json.JSONObject
class LiveQualityHook(classLoader: ClassLoader) : BaseHook(classLoader) {
companion object {
private const val TAG = "LiveQualityHook"
}
@Volatile
private var newQn: String = ""
private fun debug(msg: () -> String) {
if (BuildConfig.DEBUG) {
Log.d("[$TAG] - ${msg()}")
}
}
override fun startHook() {
val liveQuality = sPrefs.getString("live_quality", "0")?.toIntOrNull() ?: 0
if (liveQuality <= 0) {
return
}
val canSwitchLiveRoom = !sPrefs.getBoolean("forbid_switch_live_room", false)
instance.defaultRequestInterceptClass?.hookBeforeAllMethods(instance.interceptMethod()) { param ->
val request = param.args[0]
val httpUrl = request.getObjectField(instance.urlField())
val url = httpUrl.toString()
if (!url.startsWith("https://api.live.bilibili.com/xlive/app-room/v2/index/getRoomPlayInfo?")) {
return@hookBeforeAllMethods
}
debug { "oldHttpUrl: $url" }
val uri = Uri.parse(httpUrl.toString())
val qn = uri.getQueryParameter("qn")
if (qn.isNullOrEmpty() || qn == "0") {
val builder = uri.buildUpon().clearQuery()
for (name in uri.queryParameterNames) {
if (name == "qn") {
builder.appendQueryParameter(name, newQn.ifEmpty { liveQuality.toString() })
} else {
builder.appendQueryParameter(name, uri.getQueryParameter(name))
}
}
val newHttpUrl = instance.httpUrlClass?.callStaticMethod(
instance.httpUrlParseMethod(),
builder.build().toString()
)
debug { "newHttpUrl: $newHttpUrl" }
request.setObjectField(instance.urlField(), newHttpUrl)
}
}
instance.retrofitResponseClass?.hookBeforeAllConstructors { param ->
val url = getRetrofitUrl(param.args[0]) ?: return@hookBeforeAllConstructors
val body = param.args[1] ?: return@hookBeforeAllConstructors
when {
instance.generalResponseClass?.isInstance(body) != true -> Unit
// 处理上下滑动切换直播间
url.startsWith("https://api.live.bilibili.com/xlive/app-interface/v2/room/recList?") && canSwitchLiveRoom -> {
val data = body.getObjectField("data") ?: return@hookBeforeAllConstructors
val info = JSONObject(
instance.fastJsonClass?.callStaticMethod("toJSONString", data).toString()
)
if (fixLiveRoomFeedInfo(info, liveQuality)) {
body.setObjectField(
"data",
instance.fastJsonClass?.callStaticMethod(
instance.fastJsonParse(),
info.toString(),
data.javaClass
)
)
}
}
BuildConfig.DEBUG && url.startsWith("https://api.live.bilibili.com/xlive/app-room/v2/index/getRoomPlayInfo?") -> {
val data = body.getObjectField("data") ?: return@hookBeforeAllConstructors
val info = JSONObject(
instance.fastJsonClass?.callStaticMethod("toJSONString", data).toString()
)
printCodec(info)
}
}
}
instance.liveRTCSourceServiceImplClass?.hookBeforeAllMethods(instance.switchAutoMethod()) { param ->
val mode = param.args[0] as? Enum<*> ?: return@hookBeforeAllMethods
if (mode.ordinal == 2) { // AUTO
param.result = null
}
}
instance.livePlayUrlSelectUtilClass?.hookBeforeMethod(
instance.buildSelectorDataMethod(),
Uri::class.java
) { param ->
val originalUri = param.args[0] as Uri
if (!originalUri.isLive()) {
return@hookBeforeMethod
}
debug { "originalLiveUrl: $originalUri" }
if (originalUri.getQueryParameter("no_playurl") == "1") {
newQn = liveQuality.toString()
} else {
newQn = findQuality(
JSONArray(originalUri.getQueryParameter("accept_quality")),
liveQuality
).toString()
param.args[0] = originalUri.modified(
removeIf = { name ->
name.startsWith("playurl")
},
append = mapOf("no_playurl" to "1")
).also {
debug { "newLiveUrl: $it" }
}
}
debug { "newQn: $newQn" }
}
}
private fun Uri.isLive(): Boolean {
return scheme in arrayOf("http", "https")
&& host == "live.bilibili.com"
&& pathSegments.firstOrNull()?.all { it.isDigit() } == true
}
private fun findQuality(acceptQuality: JSONArray, expectQuality: Int): Int {
val acceptQnList = acceptQuality.asSequence<Int>().sorted().toList()
val max = acceptQnList.max()
val min = acceptQnList.min()
return when {
expectQuality > max -> max
expectQuality < min -> min
else -> acceptQnList.first { it >= expectQuality }
}
}
private fun Uri.modified(removeIf: (String) -> Boolean, append: Map<String, Any>): Uri {
val newBuilder = buildUpon().clearQuery()
for (name in queryParameterNames) {
val value = getQueryParameter(name) ?: ""
if (!removeIf(name)) {
newBuilder.appendQueryParameter(name, value)
}
}
append.forEach { (k, v) ->
newBuilder.appendQueryParameter(k, v.toString())
}
return newBuilder.build()
}
private fun fixLiveRoomFeedInfo(info: JSONObject, expectQuality: Int): Boolean {
val feedList = info.optJSONArray("list") ?: return false
feedList.iterator().forEach { feedData ->
debug { "oldFeedData: ${feedData.toString(2)}" }
val newQuality = findQuality(feedData.getJSONArray("accept_quality"), expectQuality)
feedData.apply {
put("current_qn", newQuality)
put("current_quality", newQuality)
put("play_url", "")
put("play_url_h265", "")
put("playurl_infos", JSONArray())
}
debug { "newFeedData: ${feedData.toString(2)}" }
}
return true
}
private fun printCodec(info: JSONObject) {
val playUrlInfo = info.optJSONObject("playurl_info") ?: return
val playUrlObj = playUrlInfo.getJSONObject("playurl")
debug { "printCodec >>>>>>>>>>>>>>" }
playUrlObj.getJSONArray("stream").iterator().forEach { stream ->
stream.getJSONArray("format").iterator().forEach { format ->
format.getJSONArray("codec").iterator().forEach {
debug { "codec: ${it.toString(2)}" }
}
}
}
debug { "printCodec <<<<<<<<<<<<<<" }
}
}
@@ -0,0 +1,66 @@
package me.iacn.biliroaming.hook
import android.os.Bundle
import android.view.MotionEvent
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
class LiveRoomHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (sPrefs.getBoolean("forbid_switch_live_room", false)) {
instance.livePagerRecyclerViewClass?.replaceMethod(
"onInterceptTouchEvent",
MotionEvent::class.java
) { false }
}
if (sPrefs.getBoolean("disable_live_room_double_click", false)) {
instance.liveRoomPlayerViewClass?.declaredMethods?.find { it.name == "onDoubleTap" }
?.hookBeforeMethod { param ->
runCatching {
val player = param.thisObject.callMethod("getPlayerCommonBridge")
?: return@hookBeforeMethod
val method = if (player.callMethodAs("isPlaying"))
"pause" else "resume"
player.callMethod(method)
}.onSuccess { param.result = true }
}
val lastTouchUpTimeField =
instance.liveRoomPlayerViewClass?.findFirstFieldByExactTypeOrNull(Long::class.javaPrimitiveType!!)
if (lastTouchUpTimeField != null) {
instance.liveRoomPlayerViewClass?.declaredMethods?.filter { m ->
m.isPublic && m.returnType == Void.TYPE && m.parameterTypes.let { it.size == 1 && it[0] == MotionEvent::class.java }
}?.forEach { m ->
m.hookAfterMethod { lastTouchUpTimeField.setLong(it.thisObject, 0L) }
}
}
}
if (!sPrefs.getBoolean("revert_live_room_feed", false)) {
return
}
instance.liveKvConfigHelperClass?.hookAfterMethod(
"getLocalValue",
String::class.java
) { param ->
if (param.args[0] == "live_new_room_setting") {
if (param.result != null) {
val obj = (param.result as String).toJSONObject()
if (obj.get("all_new_room_enable") == "2") {
obj.put("all_new_room_enable", "0")
param.result = obj.toString()
}
}
}
}
instance.liveRoomActivityClass?.hookBeforeMethod(
"onCreate",
Bundle::class.java
) { param ->
val intent = (param.thisObject as android.app.Activity).intent
if (intent.getStringExtra("is_room_feed") == "1") {
intent.putExtra("is_room_feed", "0")
Log.toast("已强制直播间使用旧版样式")
}
}
}
}
@@ -0,0 +1,23 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.hookAfterAllConstructors
import me.iacn.biliroaming.utils.sPrefs
class LongPressSpeed(cl: ClassLoader) : BaseHook(cl) {
private val speed: Float = sPrefs.getInt("long_press_speed", 300) / 100f
override fun startHook() {
if (speed == 3f) return
instance.tripleSpeedServiceClass!!.hookAfterAllConstructors {
val obj = it.thisObject
obj::class.java.getDeclaredField("\$speed").apply {
isAccessible = true
set(obj, speed)
}
}
}
}
@@ -0,0 +1,21 @@
package me.iacn.biliroaming.hook
import android.app.Activity
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.hookBeforeAllMethods
import me.iacn.biliroaming.utils.replaceMethod
import me.iacn.biliroaming.utils.sPrefs
class MultiWindowHook(mClassLoader: ClassLoader) : BaseHook(mClassLoader) {
override fun startHook() {
if (sPrefs.getBoolean("fake_non_multiwindow", false).not()) return
Log.d("startHook: MultiWindowHook")
Activity::class.java
.getDeclaredMethod("isInMultiWindowMode")
.replaceMethod { false }
Activity::class.java
.hookBeforeAllMethods("onMultiWindowModeChanged") { param ->
param.args[0] = false
}
}
}
@@ -0,0 +1,50 @@
package me.iacn.biliroaming.hook
import de.robv.android.xposed.XposedHelpers
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.findClassOrNull
import me.iacn.biliroaming.utils.hookBeforeMethod
import me.iacn.biliroaming.utils.sPrefs
class MusicNotificationHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("music_notification", false)) return
Log.d("startHook: MusicNotification")
"com.bilibili.lib.blconfig.ConfigManager\$Companion".findClassOrNull(mClassLoader)?.run {
hookBeforeMethod(
"isHitFF",
String::class.java
) { param ->
(param.args[0] as String).run {
if (this == "ff_background_use_system_media_controls") {
param.result = true
}
}
}
}
"com.bilibili.lib.dd.DeviceDecision".findClassOrNull(mClassLoader)?.hookBeforeMethod(
"getBoolean",
String::class.java,
Boolean::class.javaPrimitiveType
) { param ->
if (param.args[0] == "dd_enable_system_media_control") {
param.result = true
}
}
// Play store
"com.bilibili.lib.blconfig.ConfigManager\$a".findClassOrNull(mClassLoader)?.run {
XposedHelpers.findMethodExactIfExists(this, "g", String::class.java)?.hookBeforeMethod {
(it.args[0] as String).run {
if (this == "ff_background_use_system_media_controls") {
it.result = true
}
}
}
}
}
}
@@ -0,0 +1,39 @@
package me.iacn.biliroaming.hook
import android.content.Context
import android.os.Bundle
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
class P2pHook(classLoader: ClassLoader) : BaseHook(classLoader) {
private val blockPcdn = sPrefs.getBoolean("block_pcdn", false)
private val blockPcdnLive = sPrefs.getBoolean("block_pcdn_live", false)
override fun startHook() {
if (!blockPcdn && !blockPcdnLive) return
Log.d("startHook: P2P")
"tv.danmaku.ijk.media.player.IjkMediaPlayer\$IjkMediaPlayerServiceConnection".from(
mClassLoader
)?.replaceMethod("initP2PClient") {}
if (blockPcdn) {
"tv.danmaku.ijk.media.player.P2P".from(mClassLoader)?.run {
hookBeforeMethod("getInstance", Context::class.java, Bundle::class.java) { param ->
param.args[0] = null
param.args[1].callMethod("clear")
}
hookBeforeConstructor(Context::class.java, Bundle::class.java) { param ->
param.args[0] = null
param.args[1].callMethod("clear")
}
}
}
if (blockPcdnLive) {
instance.liveRtcEnable()?.let {
instance.liveRtcEnableClass?.replaceMethod(it) { false }
}
"com.bilibili.bililive.playercore.p2p.P2PType".from(mClassLoader)?.run {
hookBeforeMethod("create", Int::class.javaPrimitiveType) { it.args[0] = 0 }
hookBeforeMethod("createTo", Int::class.javaPrimitiveType) { it.args[0] = 0 }
}
}
}
}
@@ -0,0 +1,784 @@
package me.iacn.biliroaming.hook
import de.robv.android.xposed.XC_MethodHook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
import me.iacn.biliroaming.utils.json.FastjsonHelper
import me.iacn.biliroaming.utils.json.GsonHelper
import me.iacn.biliroaming.utils.json.JsonHelper
import me.iacn.biliroaming.utils.json.getObjectAs
import me.iacn.biliroaming.utils.json.getObjectAsHelperOrNull
import me.iacn.biliroaming.utils.json.toJsonHelper
class PegasusHook(classLoader: ClassLoader) : BaseHook(classLoader) {
private val hidden = sPrefs.getBoolean("hidden", false)
private val filterSet = sPrefs.getStringSet("customize_home_recommend", emptySet()).orEmpty()
private val hideLowPlayCountLimit = sPrefs.getLong("hide_low_play_count_recommend_limit", 0)
private val hideShortDurationLimit = sPrefs.getInt("hide_short_duration_recommend_limit", 0)
private val hideLongDurationLimit = sPrefs.getInt("hide_long_duration_recommend_limit", 0)
private val kwdFilterTitleList by lazy {
migrateHomeFilterPrefsIfNeeded()
sPrefs.getStringSet("home_filter_keywords_title", null).orEmpty()
}
private val kwdFilterTitleRegexes by lazy { kwdFilterTitleList.map { it.toRegex() } }
private val kwdFilterTitleRegexMode = sPrefs.getBoolean("home_filter_title_regex_mode", false)
private val kwdFilterReasonList by lazy {
migrateHomeFilterPrefsIfNeeded()
sPrefs.getStringSet("home_filter_keywords_reason", null).orEmpty()
}
private val kwdFilterReasonRegexes by lazy { kwdFilterReasonList.map { it.toRegex() } }
private val kwdFilterReasonRegexMode = sPrefs.getBoolean("home_filter_reason_regex_mode", false)
private val kwdFilterUidList by lazy {
migrateHomeFilterPrefsIfNeeded()
sPrefs.getStringSet("home_filter_keywords_uid", null)
?.mapNotNull { it.toLongOrNull() }.orEmpty()
}
private val kwdFilterUpnameList by lazy {
migrateHomeFilterPrefsIfNeeded()
sPrefs.getStringSet("home_filter_keywords_up", null).orEmpty()
}
private val kwdFilterUpnameRegexes by lazy { kwdFilterUpnameList.map { it.toRegex() } }
private val kwdFilterUpnameRegexMode = sPrefs.getBoolean("home_filter_up_regex_mode", false)
private val kwdFilterRnameList by lazy {
migrateHomeFilterPrefsIfNeeded()
sPrefs.getStringSet("home_filter_keywords_category", null).orEmpty()
}
private val kwdFilterTnameList by lazy {
migrateHomeFilterPrefsIfNeeded()
sPrefs.getStringSet("home_filter_keywords_channel", null).orEmpty()
}
private val disableAutoRefresh = sPrefs.getBoolean("disable_auto_refresh", false)
private val removeRelatePromote = sPrefs.getBoolean("remove_video_relate_promote", false)
private val removeRelateOnlyAv = sPrefs.getBoolean("remove_video_relate_only_av", false)
private val removeRelateNothing = sPrefs.getBoolean("remove_video_relate_nothing", false)
private val applyToRelate = sPrefs.getBoolean("home_filter_apply_to_relate", false)
private val hideTopEntrance = sPrefs.getBoolean("hide_top_entrance_popular", false)
private val hideSuggestFollow = sPrefs.getBoolean("hide_suggest_follow_popular", false)
private val hideTopicList = sPrefs.getBoolean("hide_topic_list_popular", true)
private val filterMap = mapOf(
"advertisement" to listOf("ad", "cm", "cm_v2"),
"article" to listOf("article"),
"bangumi" to listOf("bangumi", "special", "pgc"),
"game" to listOf("game"),
"picture" to listOf("picture"),
"vertical" to listOf("vertical", "story"),
"banner" to listOf("banner"),
"live" to listOf("live"),
"inline" to listOf("inline"),
"notify" to listOf("notify_tunnel"),
"large_cover" to listOf("large_cover"),
"middle_cover" to listOf("middle_cover"),
"small_cover" to listOf("small_cover"),
)
private val filter = filterSet.flatMap {
filterMap[it].orEmpty()
}
val handleCoverRatio = sPrefs.getString("pegasus_cover_ratio", "0")?.toFloat() ?: 0f
companion object {
private const val REASON_ID_TITLE = 1145140L
private const val REASON_ID_RCMD_REASON = 1145141L
private const val REASON_ID_UP_ID = 1145142L
private const val REASON_ID_UP_NAME = 1145143L
private const val REASON_ID_CATEGORY_NAME = 1145144L
private const val REASON_ID_CHANNEL_NAME = 1145145L
private val blockReasonIds = arrayOf(
REASON_ID_TITLE,
REASON_ID_RCMD_REASON,
REASON_ID_UP_ID,
REASON_ID_UP_NAME,
REASON_ID_CATEGORY_NAME,
REASON_ID_CHANNEL_NAME,
)
private var popularDataVersion = ""
private var popularDataCount = 0L
}
private fun String.isNum() = isNotEmpty() && all { it.isDigit() }
private fun String.toPlayCount() = runCatchingOrNull {
when {
isNum() -> toDouble().toLong()
contains("万") -> (replace("万", "").toDouble() * 10_000).toLong()
contains("亿") -> (replace("亿", "").toDouble() * 100_000_000).toLong()
else -> -1L
}
} ?: -1L
// 屏蔽过低的播放数
private fun isLowCountVideo(obj: JsonHelper): Boolean {
if (hideLowPlayCountLimit == 0L) return false
val text = obj.runCatchingOrNull {
getObjectAs<String?>("cover_left_text_1")
}.orEmpty()
return text.toPlayCount().let {
if (it == -1L) false
else it < hideLowPlayCountLimit
}
}
private fun isLowCountVideoUnite(count: Long): Boolean {
if (hideLowPlayCountLimit == 0L) return false
return count < hideLowPlayCountLimit
}
// 屏蔽指定播放时长
private fun durationVideo(obj: JsonHelper): Boolean {
if (hideLongDurationLimit == 0 && hideShortDurationLimit == 0)
return false
val duration = obj.getObjectAsHelperOrNull("player_args")
?.getObjectAs("duration") ?: 0
if (hideLongDurationLimit != 0 && duration > hideLongDurationLimit)
return true
return hideShortDurationLimit != 0 && duration < hideShortDurationLimit
}
private fun durationVideoUnite(duration: Long): Boolean {
if (hideLongDurationLimit == 0 && hideShortDurationLimit == 0)
return false
if (hideLongDurationLimit != 0 && duration > hideLongDurationLimit)
return true
return hideShortDurationLimit != 0 && duration < hideShortDurationLimit
}
private fun isContainsBlockKwd(obj: JsonHelper): Boolean {
// 屏蔽标题关键词
if (kwdFilterTitleList.isNotEmpty()) {
val title = obj.getObjectAs<String?>("title").orEmpty()
if (kwdFilterTitleRegexMode && title.isNotEmpty()) {
if (kwdFilterTitleRegexes.any { title.contains(it) })
return true
} else if (title.isNotEmpty()) {
if (kwdFilterTitleList.any { title.contains(it) })
return true
}
}
// 屏蔽UID
if (kwdFilterUidList.isNotEmpty()) {
val uid = obj.getObjectAsHelperOrNull("args")?.getObjectAs<Long>("up_id") ?: 0L
if (uid != 0L && kwdFilterUidList.any { it == uid })
return true
}
// 屏蔽UP主
if (kwdFilterUpnameList.isNotEmpty()) {
val upname = if (obj.getObjectAs<String?>("goto") == "picture") {
obj.runCatchingOrNull { getObjectAs<String?>("desc") }.orEmpty()
} else {
obj.getObjectAsHelperOrNull("args")?.getObjectAs<String?>("up_name").orEmpty()
}
if (kwdFilterUpnameRegexMode && upname.isNotEmpty()) {
if (kwdFilterUpnameRegexes.any { upname.contains(it) })
return true
} else if (upname.isNotEmpty()) {
if (kwdFilterUpnameList.any { upname.contains(it) })
return true
}
}
// 屏蔽分区
if (kwdFilterRnameList.isNotEmpty()) {
val rname = obj.getObjectAsHelperOrNull("args")
?.getObjectAs<String?>("rname").orEmpty()
if (rname.isNotEmpty() && kwdFilterRnameList.any { rname.contains(it) })
return true
}
// 屏蔽频道
if (kwdFilterTnameList.isNotEmpty()) {
val tname = obj.getObjectAsHelperOrNull("args")
?.getObjectAs<String?>("tname").orEmpty()
if (tname.isNotEmpty() && kwdFilterTnameList.any { tname.contains(it) })
return true
}
// 屏蔽推荐关键词(可能不存在,必须放最后)
if (kwdFilterReasonList.isNotEmpty()) {
val reason = obj.runCatchingOrNull {
getObjectAsHelperOrNull("rcmd_reason")?.getObjectAs<String?>("text").orEmpty()
}.orEmpty()
if (kwdFilterReasonRegexMode && reason.isNotEmpty()) {
if (kwdFilterReasonRegexes.any { reason.contains(it) })
return true
} else if (reason.isNotEmpty()) {
if (kwdFilterReasonList.any { reason.contains(it) })
return true
}
}
return false
}
private fun isContainsBlockKwdUnite(card: Any): Boolean {
if (card.callMethodAs("hasBasicInfo")) {
card.callMethodAs<Any>("getBasicInfo").let { basicInfo ->
// 屏蔽标题关键词
if (kwdFilterTitleList.isNotEmpty()) {
val title = basicInfo.callMethodAs<String>("getTitle")
if (kwdFilterTitleRegexMode && title.isNotEmpty()) {
if (kwdFilterTitleRegexes.any { title.contains(it) })
return true
} else if (title.isNotEmpty()) {
if (kwdFilterTitleList.any { title.contains(it) })
return true
}
}
basicInfo.callMethodAs<Any>("getAuthor").let { author ->
// 屏蔽UID
if (kwdFilterUidList.isNotEmpty()) {
val uid = author.callMethodAs<Long>("getMid")
if (uid != 0L && kwdFilterUidList.any { it == uid })
return true
}
// 屏蔽UP主
if (kwdFilterUpnameList.isNotEmpty()) {
val upName = author.callMethodAs<String>("getTitle")
if (kwdFilterUpnameRegexMode && upName.isNotEmpty()) {
if (kwdFilterUpnameRegexes.any { upName.contains(it) })
return true
} else if (upName.isNotEmpty()) {
if (kwdFilterUpnameList.any { upName.contains(it) })
return true
}
}
}
}
}
return false
}
private fun ArrayList<Any>.appendReasons(jsonHelper: Class<JsonHelper>) = this.forEach {
val item = it.toJsonHelper(jsonHelper)
val title = item.getObjectAs<String?>("title").orEmpty()
val rcmdReason = item.runCatchingOrNull {
getObjectAsHelperOrNull("rcmd_reason")?.getObjectAs<String?>("text")
}.orEmpty()
val args = item.getObjectAsHelperOrNull("args")
val upId = args?.getObjectAs<Long>("up_id") ?: 0L
val upName = if (item.getObject("goto") == "picture") {
item.runCatchingOrNull { getObjectAs<String?>("desc") }.orEmpty()
} else {
args?.getObjectAs<String?>("up_name").orEmpty()
}
val categoryName = args?.getObjectAs<String?>("rname").orEmpty()
val channelName = args?.getObjectAs<String?>("tname").orEmpty()
val treePoint = item.getObjectAs<MutableList<Any>?>("three_point_v2")
val reasons = mutableListOf<Any>()
instance.treePointItemClass?.new()?.apply {
setObjectField("title", "漫游屏蔽")
setObjectField("subtitle", "(本地屏蔽,重启生效,可前往首页推送过滤器查看)")
setObjectField("type", "dislike")
if (title.isNotEmpty()) {
instance.dislikeReasonClass?.new()?.apply {
setLongField("id", REASON_ID_TITLE)
setObjectField("name", "标题:$title")
}?.let { reasons.add(it) }
}
if (rcmdReason.isNotEmpty()) {
instance.dislikeReasonClass?.new()?.apply {
setLongField("id", REASON_ID_RCMD_REASON)
setObjectField("name", "推荐原因:$rcmdReason")
}?.let { reasons.add(it) }
}
if (upId != 0L) {
instance.dislikeReasonClass?.new()?.apply {
setLongField("id", REASON_ID_UP_ID)
setObjectField("name", "UID:$upId")
}?.let { reasons.add(it) }
}
if (upName.isNotEmpty()) {
instance.dislikeReasonClass?.new()?.apply {
setLongField("id", REASON_ID_UP_NAME)
setObjectField("name", "UP主:$upName")
}?.let { reasons.add(it) }
}
if (categoryName.isNotEmpty()) {
instance.dislikeReasonClass?.new()?.apply {
setLongField("id", REASON_ID_CATEGORY_NAME)
setObjectField("name", "分区:$categoryName")
}?.let { reasons.add(it) }
}
if (channelName.isNotEmpty()) {
instance.dislikeReasonClass?.new()?.apply {
setLongField("id", REASON_ID_CHANNEL_NAME)
setObjectField("name", "频道:$channelName")
}?.let { reasons.add(it) }
}
setObjectField("reasons", reasons)
}?.let {
if (treePoint != null && reasons.isNotEmpty()) {
treePoint.add(it)
} else if (reasons.isNotEmpty()) {
item.setObjectField("threePoint", mutableListOf(it))
}
}
}
private fun Any.disableAutoRefresh() {
if (!disableAutoRefresh) return
// only exist on android and android_i now
runCatchingOrNull {
setLongField("autoRefreshTimeByActive", -1L)
setLongField("autoRefreshTimeByAppear", -1L)
setIntField("autoRefreshTimeByBehavior", -1)
}
// only exist on android now
runCatchingOrNull {
setIntField("autoRefreshByBehavior", -1)
}
// only exist on android_hd now
runCatchingOrNull {
setIntField("auto_refresh_time", 0)
}
}
private fun Any.customSmallCoverWhRatio() {
if (handleCoverRatio == 0f) return
runCatchingOrNull {
setFloatField("smallCoverWhRatio", handleCoverRatio)
}
}
private fun isPromoteRelate(item: Any) = removeRelatePromote
&& (item.callMethod("getFromSourceType") == 2L ||
item.callMethod("getGoto") == "cm")
private fun isNotAvRelate(item: Any) = removeRelatePromote && removeRelateOnlyAv
&& item.callMethod("getGoto") != "av"
private fun isLowCountRelate(item: Any): Boolean {
if (hideLowPlayCountLimit == 0L) return false
val text = item.callMethod("getStatV2")?.callMethod("getViewVt")
?.callMethodAs<String>("getText").orEmpty()
return text.toPlayCount().let {
if (it == -1L) false
else it < hideLowPlayCountLimit
}
}
private fun isDurationInvalidRelate(item: Any): Boolean {
if (hideLongDurationLimit == 0 && hideShortDurationLimit == 0)
return false
val duration = item.callMethodAs("getDuration") ?: 0L
if (hideLongDurationLimit != 0 && duration > hideLongDurationLimit)
return true
return hideShortDurationLimit != 0 && duration < hideShortDurationLimit
}
private fun isContainsBlockKwdRelate(item: Any): Boolean {
if (kwdFilterTitleList.isNotEmpty()) {
val title = item.callMethodAs<String>("getTitle")
if (kwdFilterTitleRegexMode && title.isNotEmpty()) {
if (kwdFilterTitleRegexes.any { title.contains(it) })
return true
} else if (title.isNotEmpty()) {
if (kwdFilterTitleList.any { title.contains(it) })
return true
}
}
if (kwdFilterUidList.isNotEmpty()) {
val uid = item.callMethod("getAuthor")
?.callMethodAs("getMid") ?: 0L
if (uid != 0L && kwdFilterUidList.any { it == uid })
return true
}
if (kwdFilterUpnameList.isNotEmpty()) {
val upname = item.callMethod("getAuthor")
?.callMethodAs<String>("getName").orEmpty()
if (kwdFilterUpnameRegexMode && upname.isNotEmpty()) {
if (kwdFilterUpnameRegexes.any { upname.contains(it) })
return true
} else if (upname.isNotEmpty()) {
if (kwdFilterUpnameList.any { upname.contains(it) })
return true
}
}
if (kwdFilterReasonList.isNotEmpty()) {
val reason = item.callMethod("getRcmdReasonStyle")
?.callMethodAs<String>("getText").orEmpty()
if (kwdFilterReasonRegexMode && reason.isNotEmpty()) {
if (kwdFilterReasonRegexes.any { reason.contains(it) })
return true
} else if (reason.isNotEmpty()) {
if (kwdFilterReasonList.any { reason.contains(it) })
return true
}
}
return false
}
private fun isLowCountVideoPopular(obj: Any): Boolean {
if (hideLowPlayCountLimit == 0L) return false
val rightDesc2 = obj.callMethodAs<String>("getRightDesc2")
val text = rightDesc2.split(' ').first().removeSuffix("观看")
return text.toPlayCount().let {
if (it == -1L) false
else it < hideLowPlayCountLimit
}
}
private fun durationVideoPopular(obj: Any): Boolean {
fun getTimeInSeconds(time: String): Long {
val parts = time.split(":").map { it.toInt() }
val seconds: Long = when (parts.size) {
2 -> parts[0] * 60L + parts[1]
3 -> parts[0] * 3600L + parts[1] * 60L + parts[2]
else -> Long.MAX_VALUE
}
return seconds
}
if (hideLongDurationLimit == 0 && hideShortDurationLimit == 0)
return false
val text = obj.callMethodAs<String>("getCoverRightText1")
val duration = getTimeInSeconds(text)
if (hideLongDurationLimit != 0 && duration > hideLongDurationLimit)
return true
return hideShortDurationLimit != 0 && duration < hideShortDurationLimit
}
private fun isContainsBlockKwdPopular(obj: Any, base: Any?): Boolean {
base?: return false
val threePointV4 = base.callMethod("getThreePointV4")
val sharePlane = threePointV4?.callMethod("getSharePlane")
if (kwdFilterTitleList.isNotEmpty()) {
val title = base.callMethodAs<String>("getTitle")
if (kwdFilterTitleRegexMode && title.isNotEmpty()) {
if (kwdFilterTitleRegexes.any { title.contains(it) })
return true
} else if (title.isNotEmpty()) {
if (kwdFilterTitleList.any { title.contains(it) }) {
return true
}
}
}
if (kwdFilterUidList.isNotEmpty()) {
val uid = sharePlane?.callMethodAs<Long>("getAuthorId") ?: 0
if (uid != 0L && kwdFilterUidList.any { it == uid })
return true
}
if (kwdFilterUpnameList.isNotEmpty()) {
val upname = obj.callMethodAs<String>("getRightDesc1")
if (kwdFilterUpnameRegexMode && upname.isNotEmpty()) {
if (kwdFilterUpnameRegexes.any { upname.contains(it) })
return true
} else if (upname.isNotEmpty()) {
if (kwdFilterUpnameList.any { upname.contains(it) })
return true
}
}
if (kwdFilterReasonList.isNotEmpty()) {
val reasonStyle = obj.callMethod("getRcmdReasonStyle")
val reasonText = reasonStyle?.callMethodAs<String>("getText")
do {
if (reasonText.isNullOrEmpty()) {
break
}
if (kwdFilterReasonRegexMode && kwdFilterReasonRegexes.any { reasonText.contains(it) }) {
return true
} else if (kwdFilterReasonList.any { reasonText.contains(it) }) {
return true
}
} while (false)
}
return false
}
private fun cardV5Handle(obj: Any?): Boolean {
obj ?: return false
val base = obj.callMethod("getBase")
if (popularDataCount % 10 == 0L) {
popularDataVersion = base?.callMethodAs<String>("getParam") ?: popularDataVersion
}
return isLowCountVideoPopular(obj) || durationVideoPopular(obj) || isContainsBlockKwdPopular(obj, base)
}
override fun startHook() {
Log.d("startHook: Pegasus")
fun hookPegasusFeedConvert(json: JsonHelper) {
json.getObject("config")!!.apply {
disableAutoRefresh()
customSmallCoverWhRatio()
}
if (!hidden) return
json.getObjectAs<ArrayList<Any>>("items").run {
removeAll {
val item = it.toJsonHelper(json.javaClass)
val cardGoto = item.getObjectAs<String?>("card_goto").orEmpty()
val cardType = item.getObjectAs<String?>("card_type").orEmpty()
val goto = item.getObjectAs<String?>("goto").orEmpty()
filter.any {
it in cardGoto || it in cardType || it in goto
} || isLowCountVideo(item) || isContainsBlockKwd(item) || durationVideo(item)
}
appendReasons(json.javaClass)
}
}
instance.pegasusFeedClass?.hookAfterMethod(
"convert",
Object::class.java
) { param ->
val data = param.result?.getObjectField("data") ?: return@hookAfterMethod
val json = data.toJsonHelper<FastjsonHelper>()
hookPegasusFeedConvert(json)
}
instance.pegasusParserClass?.hookAfterMethod(
"convert",
Object::class.java
) { param ->
val data = param.result?.getObjectField("data") ?: return@hookAfterMethod
val json = data.toJsonHelper<GsonHelper>()
hookPegasusFeedConvert(json)
}
if (!hidden) return
fun MutableList<Any>.filter() = removeAll {
isPromoteRelate(it) || isNotAvRelate(it) || (applyToRelate && (isLowCountRelate(it)
|| isDurationInvalidRelate(it) || isContainsBlockKwdRelate(it)))
}
fun MutableList<Any>.filterUnite() = removeAll {
val allowTypeList = mutableListOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
var shouldFiltered = false
allowTypeList.removeAll { digit ->
(removeRelateOnlyAv && digit != 1) || (removeRelatePromote && digit in listOf(
3, // Resource, like mall
4, // GAME
5, // CM
10 // SPECIAL
))
}
// av filter
if (applyToRelate) {
if (it.callMethodAs("hasAv")) {
it.callMethodAs<Any>("getAv").let { av ->
val duration = av.callMethodAs<Long>("getDuration")
if (durationVideoUnite(duration)) {
shouldFiltered = true
return@let
}
if (av.callMethodAs("hasStat")) {
av.callMethodAs<Any>("getStat").let { stat ->
if (stat.callMethodAs("hasVt")) {
stat.callMethodAs<Any>("getVt").let { vt ->
if (isLowCountVideoUnite(vt.callMethodAs<Long>("getValue"))) {
shouldFiltered = true
return@let
}
}
}
}
}
// rcmd_reason 推荐理由
if (av.callMethodAs("hasRcmdReason")) {
av.callMethodAs<Any>("getRcmdReason").let { rcmd ->
if (kwdFilterReasonList.isNotEmpty()) {
val reason = rcmd.callMethodAs<String>("getText")
if (kwdFilterReasonRegexMode && reason.isNotEmpty()) {
if (kwdFilterReasonRegexes.any { reason.contains(it) })
shouldFiltered = true
return@let
} else if (reason.isNotEmpty()) {
if (kwdFilterReasonList.any { reason.contains(it) })
shouldFiltered = true
return@let
}
}
}
}
}
if (isContainsBlockKwdUnite(it)) {
shouldFiltered = true
}
}
}
removeRelateNothing || it.callMethodAs("getRelateCardTypeValue") !in allowTypeList || shouldFiltered
}
instance.viewMossClass?.hookAfterMethod(
if (instance.useNewMossFunc) "executeView" else "view",
instance.viewReqClass
) { param ->
param.result ?: return@hookAfterMethod
if (removeRelatePromote && removeRelateOnlyAv && removeRelateNothing) {
param.result.callMethod("clearRelates")
param.result.callMethod("clearPagination")
return@hookAfterMethod
}
param.result.callMethod("ensureRelatesIsMutable")
param.result.callMethodAs<MutableList<Any>>("getRelatesList").filter()
}
instance.viewMossClass?.hookAfterMethod(
if (instance.useNewMossFunc) "executeRelatesFeed" else "relatesFeed",
"com.bapis.bilibili.app.view.v1.RelatesFeedReq"
) { param ->
param.result ?: return@hookAfterMethod
param.result.callMethod("ensureListIsMutable")
param.result.callMethodAs<MutableList<Any>>("getListList").filter()
}
instance.viewUniteMossClass?.run {
hookAfterMethod(
if (instance.useNewMossFunc) "executeView" else "view",
instance.viewUniteReqClass
) { param ->
param.result ?: return@hookAfterMethod
param.result.callMethod("getTab")?.run {
callMethod("ensureTabModuleIsMutable")
callMethodAs<MutableList<Any>>("getTabModuleList").map { originalTabModules ->
if (!originalTabModules.callMethodAs<Boolean>("hasIntroduction")) return@map
originalTabModules.callMethodAs<Any>("getIntroduction").run {
callMethod("ensureModulesIsMutable")
callMethodAs<MutableList<Any>>("getModulesList").map { module ->
if (!module.callMethodAs<Boolean>("hasRelates")) return@map
module.callMethodAs<Any>("getRelates").run {
callMethod("ensureCardsIsMutable")
callMethodAs<MutableList<Any>>("getCardsList").filterUnite()
}
}
}
}
}
}
hookAfterMethod(
if (instance.useNewMossFunc) "executeRelatesFeed" else "relatesFeed",
"com.bapis.bilibili.app.viewunite.v1.RelatesFeedReq"
) { param ->
param.result?.run {
callMethod("ensureRelatesIsMutable")
callMethodAs<MutableList<Any>>("getRelatesList").filterUnite()
}
}
}
fun hookDislikeReason(reason: Any?): Boolean {
if (reason == null || reason.getLongField("id") !in blockReasonIds)
return false
val id = reason.getLongField("id")
val name = reason.getObjectFieldAs<String?>("name").orEmpty()
val value = name.substringAfter(":")
when (id) {
REASON_ID_TITLE -> {
val validValue =
if (kwdFilterTitleRegexMode) Regex.escape(value) else value
sPrefs.appendStringForSet("home_filter_keywords_title", validValue)
}
REASON_ID_RCMD_REASON -> {
val validValue =
if (kwdFilterReasonRegexMode) Regex.escape(value) else value
sPrefs.appendStringForSet("home_filter_keywords_reason", validValue)
}
REASON_ID_UP_ID -> {
sPrefs.appendStringForSet("home_filter_keywords_uid", value)
}
REASON_ID_UP_NAME -> {
val validValue =
if (kwdFilterUpnameRegexMode) Regex.escape(value) else value
sPrefs.appendStringForSet("home_filter_keywords_up", validValue)
}
REASON_ID_CATEGORY_NAME -> {
sPrefs.appendStringForSet("home_filter_keywords_category", value)
}
REASON_ID_CHANNEL_NAME -> {
sPrefs.appendStringForSet("home_filter_keywords_channel", value)
}
}
Log.toast("添加成功", force = true)
return true
}
instance.cardClickProcessorClass?.declaredMethods
?.find { it.name == instance.onFeedClicked() }?.hookBeforeMethod { param ->
if (hookDislikeReason(param.args[2])) {
param.result = null
}
}
"com.bilibili.pegasus.ext.threepoint.ThreePointKt".findClassOrNull(mClassLoader)
?.declaredMethods?.find { it.parameterTypes.size == 8 }
?.hookBeforeMethod {
if (hookDislikeReason(it.args[3])) {
it.result = null
}
}
fun MutableList<Any>.filterPopular() = removeIf {
when (it.callMethod("getItemCase")?.toString()) {
"SMALL_COVER_V5" -> {
val v5 = it.callMethod("getSmallCoverV5")
popularDataCount++
return@removeIf cardV5Handle(v5)
}
"POPULAR_TOP_ENTRANCE" -> {
return@removeIf hideTopEntrance
}
"RCMD_ONE_ITEM" -> {
popularDataCount++
return@removeIf hideSuggestFollow
}
"TOPIC_LIST" -> {
popularDataCount++
return@removeIf hideTopicList
}
else -> {
popularDataCount++
return@removeIf false
}
}
}
instance.popularClass?.hookBeforeMethod(
if (instance.useNewMossFunc) "executeIndex" else "index",
"com.bapis.bilibili.app.show.popular.v1.PopularResultReq"
) { param ->
param.args ?: return@hookBeforeMethod
val idx = param.args[0].getLongFieldOrNull("idx_")
if (idx == null || idx == 0L) {
popularDataCount = 0
popularDataVersion = ""
return@hookBeforeMethod
}
param.args[0].setObjectField("lastParam_", popularDataVersion)
param.args[0].setLongField("idx_", popularDataCount)
}
instance.popularClass?.hookAfterMethod(
if (instance.useNewMossFunc) "executeIndex" else "index",
"com.bapis.bilibili.app.show.popular.v1.PopularResultReq"
) { param ->
param.result ?: return@hookAfterMethod
param.result.callMethod("ensureItemsIsMutable")
param.result.callMethodAs<MutableList<Any>>("getItemsList").filterPopular()
}
}
}
@@ -0,0 +1,225 @@
package me.iacn.biliroaming.hook
import android.net.Uri
import me.iacn.biliroaming.*
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
import kotlin.math.ceil
class PlayArcConfHook(classLoader: ClassLoader) : BaseHook(classLoader) {
private val playURLMoss get() = instance.playURLMossClass?.new()
private val viewMoss get() = instance.viewMossClass?.new()
override fun startHook() {
if (!sPrefs.getBoolean("play_arc_conf", false)) return
instance.playURLMossClass?.hookAfterMethod(
if (instance.useNewMossFunc) "executePlayView" else "playView", instance.playViewReqClass
) { param ->
param.result?.callMethod("getPlayArc")?.run {
arrayOf(
callMethod("getCastConf"),
callMethod("getBackgroundPlayConf"),
callMethod("getSmallWindowConf")
).forEach {
it?.callMethod("setDisabled", false)
it?.callMethod("setIsSupport", true)
}
}
}
val supportedArcConf = "com.bapis.bilibili.playershared.ArcConf"
.from(mClassLoader)?.new()?.apply {
callMethod("setDisabled", false)
callMethod("setIsSupport", true)
}
val arcConfs = "com.bapis.bilibili.playershared.PlayArcConf"
.from(mClassLoader)?.callStaticMethod("getDefaultInstance")
arcConfs?.callMethodAs<LinkedHashMap<Int, Any?>>("internalGetMutableArcConfs")
?.run {
// CASTCONF,BACKGROUNDPLAY,SMALLWINDOW,LISTEN
intArrayOf(2, 9, 23, 36).forEach { this[it] = supportedArcConf }
}
instance.playerMossClass?.hookAfterMethod(
if (instance.useNewMossFunc) "executePlayViewUnite" else "playViewUnite",
instance.playViewUniteReqClass
) { param ->
param.result?.callMethod("mergePlayArcConf", arcConfs)
}
instance.playerMossClass?.hookBeforeMethod("playViewUnite", instance.playViewUniteReqClass, instance.mossResponseHandlerClass) { param ->
param.args[1] = param.args[1].mossResponseHandlerProxy { resp ->
resp?.callMethod("mergePlayArcConf", arcConfs)
}
}
"com.bapis.bilibili.app.listener.v1.ListenerMoss".from(mClassLoader)?.run {
val playlistHook = { param: MethodHookParam ->
val req = param.args[0]
param.args[1] = param.args[1].mossResponseHandlerProxy { resp ->
runCatching {
reconstructPlaylistResponse(req, resp)
}.onFailure {
Log.e(it)
Log.toast("听视频解锁失败")
}
}
}
hookBeforeMethod(
"playlist",
"com.bapis.bilibili.app.listener.v1.PlaylistReq",
instance.mossResponseHandlerClass,
hooker = playlistHook
)
hookBeforeMethod(
"rcmdPlaylist",
"com.bapis.bilibili.app.listener.v1.RcmdPlaylistReq",
instance.mossResponseHandlerClass,
hooker = playlistHook
)
hookBeforeMethod(
"playHistory",
"com.bapis.bilibili.app.listener.v1.PlayHistoryReq",
instance.mossResponseHandlerClass,
hooker = playlistHook
)
hookAfterMethod(
if (instance.useNewMossFunc) "executePlayURL" else "playURL",
"com.bapis.bilibili.app.listener.v1.PlayURLReq"
) { param ->
if (instance.networkExceptionClass?.isInstance(param.throwable) == true)
return@hookAfterMethod
val resp = param.result ?: "com.bapis.bilibili.app.listener.v1.PlayURLResp"
.on(mClassLoader).new()
val playable = resp.callMethodAs<Int>("getPlayable")
val playerInfoMap = resp.callMethodAs<Map<*, *>>("getPlayerInfoMap")
if (playable == 0 && playerInfoMap.isNotEmpty())
return@hookAfterMethod
Log.toast("听视频解锁中")
param.result = reconstructPlayUrlResponse(param.args[0], resp)
}
}
}
private fun reconstructPlaylistResponse(req: Any, resp: Any?) {
resp ?: return
val needPartItems = mutableListOf<Any>()
resp.callMethodAs<List<Any>>("getListList").filter {
it.callMethodAs<Int>("getPlayable") != 0
}.forEach {
it.callMethod("setPlayable", 0)
if (it.callMethodAs<Int>("getPartsCount") <= 0)
needPartItems.add(it)
}
if (needPartItems.isEmpty())
return
val playerArgs = runCatchingOrNull {
PlayerArgs.parseFrom(
req.callMethod("getPlayerArgs")
?.callMethodAs<ByteArray>("toByteArray")
)
} ?: playerArgs {
fnval = BangumiPlayUrlHook.MAX_FNVAL.toLong()
forceHost = 2
qn = 64
}
val commViewReq = viewReq {
fnval = playerArgs.fnval.toInt()
forceHost = playerArgs.forceHost.toInt()
fourk = 1
this.playerArgs = playerArgs
qn = playerArgs.qn.toInt()
}.toByteArray().let {
instance.viewReqClass?.callStaticMethod("parseFrom", it)
} ?: return
val bkArcPartClass = instance.bkArcPartClass ?: return
for (item in needPartItems) {
val oid = item.callMethod("getArc")?.callMethodAs<Long>("getOid")
?: continue
val viewReq = commViewReq.apply { callMethod("setAid", oid) }
val viewReply = ViewReply.parseFrom(
viewMoss?.callMethod("view", viewReq)
?.callMethodAs<ByteArray>("toByteArray") ?: continue
)
val parts = viewReply.pagesList.mapNotNull { p ->
bKArcPart {
duration = p.page.duration
this.oid = oid
page = p.page.page
subId = p.page.cid
title = p.page.part
}.toByteArray().let {
bkArcPartClass.callStaticMethod("parseFrom", it)
}
}
item.callMethod("addAllParts", parts)
}
}
private fun reconstructPlayUrlResponse(req: Any, resp: Any) = runCatching {
val reqObj = ListenPlayURLReq.parseFrom(req.callMethodAs<ByteArray>("toByteArray"))
val commPlayViewReq = playViewReq {
epId = reqObj.item.oid
qn = reqObj.playerArgs.qn
fnval = reqObj.playerArgs.fnval.toInt()
forceHost = reqObj.playerArgs.forceHost.toInt()
fourk = true
preferCodecType = CodeType.CODE265
}.toByteArray().let {
instance.playViewReqClass?.callStaticMethod("parseFrom", it)
} ?: return@runCatching resp
reqObj.item.subIdList.associateWith { subId ->
val playViewReq = commPlayViewReq.apply { callMethod("setCid", subId) }
val playViewReply = UGCPlayViewReply.parseFrom(
playURLMoss?.callMethod("playView", playViewReq)
?.callMethodAs<ByteArray>("toByteArray") ?: return@associateWith null
)
listenPlayInfo {
var deadline = 0L
fnval = reqObj.playerArgs.fnval.toInt()
format = playViewReply.videoInfo.format
length = playViewReply.videoInfo.timelength
qn = playViewReply.videoInfo.quality
videoCodecid = playViewReply.videoInfo.videoCodecid
playDash = listenPlayDASH {
playViewReply.videoInfo.dashAudioList.map {
listenDashItem {
id = it.id
size = it.size
bandwidth = it.bandwidth
baseUrl = it.baseUrl
backupUrl.addAll(it.backupUrlList)
if (deadline == 0L)
deadline = Uri.parse(baseUrl).getQueryParameter("deadline")
?.toLongOrNull() ?: 0
}
}.let { audio.addAll(it) }
duration = ceil(playViewReply.videoInfo.timelength / 1000.0).toInt()
minBufferTime = 0.0F
}
playViewReply.videoInfo.streamListList.map {
formatDescription {
it.streamInfo.let { si ->
description = si.description
displayDesc = si.displayDesc
format = si.format
quality = si.quality
superscript = si.superscript
}
}
}.let { formats.addAll(it) }
expireTime = deadline
}
}.mapNotNull { it.value?.let { v -> it.key to v } }.toMap()
.takeIf { it.isNotEmpty() }?.let {
listenPlayURLResp {
item = reqObj.item
playable = 0
playerInfo.putAll(it)
}.let {
resp.javaClass.callStaticMethod("parseFrom", it.toByteArray())
}
} ?: resp
}.onFailure {
Log.e(it)
Log.toast("听视频解锁失败")
}.getOrDefault(resp)
}
@@ -0,0 +1,31 @@
package me.iacn.biliroaming.hook
import android.view.MotionEvent
import me.iacn.biliroaming.utils.*
class PlayerLongPressHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("forbid_player_long_click_accelerate", false)) return
Log.d("startHook: PlayerLongPress")
val hooker: Hooker = { param -> param.result = true }
// pre 6.59.0
"tv.danmaku.biliplayerimpl.gesture.GestureService\$mTouchListener\$1".findClassOrNull(
mClassLoader
)?.hookBeforeMethod(
"onLongPress", MotionEvent::class.java, hooker = hooker
)
// post 6.59.0
arrayOf(
"tv.danmaku.biliplayerimpl.gesture.GestureService\$initInnerLongPressListener\$1\$onLongPress\$1",
"tv.danmaku.biliplayerimpl.gesture.GestureService\$initInnerLongPressListener\$1\$onLongPressEnd\$1",
// post 7.32.0
"com.bilibili.playerbizcommon.gesture.GestureService\$initInnerLongPressListener\$1\$onLongPress\$1",
"com.bilibili.playerbizcommon.gesture.GestureService\$initInnerLongPressListener\$1\$onLongPressEnd\$1"
).forEach { className ->
className.findClassOrNull(mClassLoader)
?.hookBeforeMethod("invoke", Object::class.java, hooker = hooker)
}
}
}
@@ -0,0 +1,463 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
class ProtoBufHook(classLoader: ClassLoader) : BaseHook(classLoader) {
companion object {
fun Any.removeCmdDms() {
callMethod("clearActivityMeta")
runCatchingOrNull {
callMethod("clearCommand")
}
setObjectField("unknownFields", instance.unknownFieldSetLiteInstance)
}
}
private val mainListReplyClass by Weak { "com.bapis.bilibili.main.community.reply.v1.MainListReply" from mClassLoader }
private val emptyPageV1Class by Weak { "com.bapis.bilibili.main.community.reply.v1.EmptyPage" from mClassLoader }
private val textV1Class by Weak { "com.bapis.bilibili.main.community.reply.v1.EmptyPage\$Text" from mClassLoader }
private val textStyleV1Class by Weak { "com.bapis.bilibili.main.community.reply.v1.TextStyle" from mClassLoader }
private val textV2Class by Weak { "com.bapis.bilibili.main.community.reply.v2.EmptyPage\$Text" from mClassLoader }
private val textStyleV2Class by Weak { "com.bapis.bilibili.main.community.reply.v2.TextStyle" from mClassLoader }
private val videoGuideClass by Weak { "com.bapis.bilibili.app.view.v1.VideoGuide" from mClassLoader }
private val unlockPlayLimit = sPrefs.getBoolean("play_arc_conf", false)
private val blockCommentGuide = sPrefs.getBoolean("block_comment_guide", false)
private val disableMainPageStory = sPrefs.getBoolean("disable_main_page_story", false)
// 以下为隐藏功能配置
private val hidden = sPrefs.getBoolean("hidden", false)
private val removeHonor = hidden && sPrefs.getBoolean("remove_video_honor", false)
private val removeUgcSeason = hidden && sPrefs.getBoolean("remove_video_UgcSeason", false)
private val removeCmdDms = hidden && sPrefs.getBoolean("remove_video_cmd_dms", false)
private val removeUpVipLabel = hidden && sPrefs.getBoolean("remove_up_vip_label", false)
// 搜索过滤
private val searchFilterUid = run {
sPrefs.getStringSet("search_filter_keyword_uid", null)
?.mapNotNull { it.toLongOrNull() }.orEmpty()
}
private val searchFilterContents = run {
sPrefs.getStringSet("search_filter_keyword_content", null).orEmpty()
}
private val searchFilterContentRegexes by lazy { searchFilterContents.map { it.toRegex() } }
private val searchFilterContentRegexMode = sPrefs.getBoolean("search_filter_content_regex_mode", false)
private val searchFilterUpNames = run {
sPrefs.getStringSet("search_filter_keyword_upname", null).orEmpty()
}
private val searchRemoveRelatePromote = sPrefs.getBoolean("search_filter_remove_relate_promote", false)
// 评论过滤
private val commentFilterBlockAtComment = sPrefs.getBoolean("comment_filter_block_at_comment", false)
private val commentFilterAtUid = run {
sPrefs.getStringSet("comment_filter_keyword_at_uid", null)
?.mapNotNull { it.toLongOrNull() }.orEmpty()
}
private val commentFilterContents = run {
sPrefs.getStringSet("comment_filter_keyword_content", null).orEmpty()
}
private val commentFilterContentRegexes by lazy { commentFilterContents.map { it.toRegex() } }
private val commentFilterContentRegexMode = sPrefs.getBoolean("comment_filter_content_regex_mode", false)
private val commentFilterAtUpNames = run {
sPrefs.getStringSet("comment_filter_keyword_at_upname", null).orEmpty()
}
private val targetCommentAuthorLevel = sPrefs.getLong("target_comment_author_level", 0L)
private val purifySearch = hidden && sPrefs.getBoolean("purify_search", false)
private val purifyCity = hidden && sPrefs.getBoolean("purify_city", false)
private val purifyCampus = hidden && sPrefs.getBoolean("purify_campus", false)
private val blockLiveOrder = hidden && sPrefs.getBoolean("block_live_order", false)
private val blockWordSearch = hidden && sPrefs.getBoolean("block_word_search", false)
private val blockModules = hidden && sPrefs.getBoolean("block_modules", false)
private val blockUpperRecommendAd = hidden && sPrefs.getBoolean("block_upper_recommend_ad", false)
private val blockVideoComment = hidden && sPrefs.getBoolean("block_video_comment", false)
private val blockViewPageAds = hidden && sPrefs.getBoolean("block_view_page_ads", false)
override fun startHook() {
hookMossView()
if (hidden && (purifyCity || purifyCampus)) {
listOf(
"com.bapis.bilibili.app.dynamic.v1.DynTabReply",
"com.bapis.bilibili.app.dynamic.v2.DynTabReply"
).forEach { clazz ->
clazz.hookAfterMethod(
mClassLoader,
"getDynTabList"
) { param ->
param.result = (param.result as List<*>).filterNot {
purifyCity && it?.callMethodAs<Long>("getCityId") != 0L
|| purifyCampus && it?.callMethodAs<String>("getAnchor") == "campus"
}
}
}
}
if (hidden && removeCmdDms) {
instance.viewMossClass?.hookAfterMethod(
if (instance.useNewMossFunc) "executeViewProgress" else "viewProgress",
"com.bapis.bilibili.app.view.v1.ViewProgressReq"
) { param ->
param.result?.callMethod("setVideoGuide", videoGuideClass?.new())
}
instance.viewUniteMossClass?.hookAfterMethod(
if (instance.useNewMossFunc) "executeViewProgress" else "viewProgress",
"com.bapis.bilibili.app.viewunite.v1.ViewProgressReq"
) { param ->
param.result?.run {
callMethod("clearDm")
callMethod("getVideoGuide")?.callMethod("clearContractCard")
}
}
instance.viewMossClass?.replaceMethod(
if (instance.useNewMossFunc) "executeTFInfo" else "tFInfo",
"com.bapis.bilibili.app.view.v1.TFInfoReq"
) { null }
instance.dmMossClass?.hookAfterMethod(
if (instance.useNewMossFunc) "executeDmView" else "dmView",
instance.dmViewReqClass,
) { it.result?.removeCmdDms() }
}
if (hidden && purifySearch) {
"com.bapis.bilibili.app.interfaces.v1.SearchMoss".hookAfterMethod(
mClassLoader,
if (instance.useNewMossFunc) "executeDefaultWords" else "defaultWords",
"com.bapis.bilibili.app.interfaces.v1.DefaultWordsReq"
) { param ->
param.result = null
}
}
if (hidden && blockWordSearch) {
"com.bapis.bilibili.main.community.reply.v1.Content".hookAfterMethod(
mClassLoader,
"internalGetUrls"
) { param ->
(param.result as LinkedHashMap<*, *>?)?.let { m ->
val iterator = m.iterator()
while (iterator.hasNext()) {
iterator.next().value.callMethodAs<String?>("getAppUrlSchema")
?.takeIf {
it.startsWith("bilibili://search?from=appcommentline_search")
}?.run {
iterator.remove()
}
}
}
}
}
if (hidden && blockModules) {
"com.bapis.bilibili.app.resource.v1.ModuleMoss".hookAfterMethod(
mClassLoader,
if (instance.useNewMossFunc) "executeList" else "list",
"com.bapis.bilibili.app.resource.v1.ListReq"
) {
it.result?.callMethod("clearPools")
}
}
if (hidden && blockUpperRecommendAd) {
"com.bapis.bilibili.ad.v1.SourceContentDto".from(mClassLoader)
?.replaceMethod("getAdContent") { null }
}
if (disableMainPageStory) {
"com.bapis.bilibili.app.distribution.setting.experimental.MultipleTusConfig"
.from(mClassLoader)?.hookAfterMethod("getTopLeft") { param ->
param.result?.runCatchingOrNull {
callMethod("clearBadge")
callMethod("clearListenBackgroundImage")
callMethod("clearListenForegroundImage")
callMethod("clearStoryBackgroundImage")
callMethod("clearStoryForegroundImage")
val tabUrl = "bilibili://root?tab_name=我的"
callMethod("getUrl")?.callMethod("setValue", tabUrl)
callMethod("getUrlV2")?.callMethod("setValue", tabUrl)
callMethod("getGoto")?.callMethod("setValue", "1")
callMethod("getGotoV2")?.callMethod("setValue", 1)
}
}
}
if (blockCommentGuide || (hidden && blockVideoComment)) {
"com.bapis.bilibili.main.community.reply.v1.ReplyMoss".hookBeforeMethod(
mClassLoader,
if (instance.useNewMossFunc) "executeMainList" else "mainList",
"com.bapis.bilibili.main.community.reply.v1.MainListReq",
) { param ->
val type = param.args[0].callMethodAs<Long>("getType")
if (hidden && blockVideoComment && type == 1L) {
val reply = mainListReplyClass?.new()?.apply {
val subjectControl = callMethod("getSubjectControl")
val emptyPage = emptyPageV1Class?.new()?.also {
subjectControl?.callMethod("setEmptyPage", it)
}
emptyPage?.callMethod(
"setImageUrl",
"https://i0.hdslb.com/bfs/app-res/android/img_holder_forbid_style1.webp"
)
textV1Class?.new()?.apply {
callMethod("setRaw", "评论区已由漫游屏蔽")
textStyleV1Class?.new()?.apply {
callMethod("setFontSize", 14)
callMethod("setTextDayColor", "#FF61666D")
callMethod("setTextNightColor", "#FFA2A7AE")
}?.let {
callMethod("setStyle", it)
}
}?.let {
emptyPage?.callMethod("addTexts", it)
}
}
param.result = reply
return@hookBeforeMethod
}
if (!blockCommentGuide) return@hookBeforeMethod
param.result.runCatchingOrNull {
callMethod("getSubjectControl")?.run {
callMethod("clearEmptyBackgroundTextPlain")
callMethod("clearEmptyBackgroundTextHighlight")
callMethod("clearEmptyBackgroundUri")
callMethod("getEmptyPage")?.let { page ->
page.callMethod("clearLeftButton")
page.callMethod("clearRightButton")
page.callMethodAs<List<Any>>("getTextsList").takeIf { it.size > 1 }
?.let {
page.callMethod("clearTexts")
page.callMethod("addTexts", it.first().apply {
callMethod("setRaw", "还没有评论哦")
})
}
}
}
}
}
"com.bapis.bilibili.main.community.reply.v2.ReplyMoss".from(mClassLoader)
?.hookBeforeMethod(
if (instance.useNewMossFunc) "executeSubjectDescription" else "subjectDescription",
"com.bapis.bilibili.main.community.reply.v2.SubjectDescriptionReq"
) { param ->
val defaultText = textV2Class?.new()?.apply {
val tipStr = if (hidden && blockVideoComment) {
"评论区已由漫游屏蔽"
} else {
"还没有评论哦"
}
callMethod("setRaw", tipStr)
textStyleV2Class?.new()?.apply {
callMethod("setFontSize", 14)
callMethod("setTextDayColor", "#FF61666D")
callMethod("setTextNightColor", "#FFA2A7AE")
}?.let {
callMethod("setStyle", it)
}
} ?: return@hookBeforeMethod
param.result.runCatchingOrNull {
callMethod("getEmptyPage")?.run {
callMethod("clearLeftButton")
callMethod("clearRightButton")
callMethod("ensureTextsIsMutable")
callMethodAs<MutableList<Any>>("getTextsList").run {
clear()
add(defaultText)
}
if (!(hidden && blockVideoComment)) return@run
callMethod(
"setImageUrl",
"https://i0.hdslb.com/bfs/app-res/android/img_holder_forbid_style1.webp"
)
}
}
}
}
val needSearchFilter = hidden and (searchFilterContents.isNotEmpty() or searchFilterUid.isNotEmpty() or searchFilterUpNames.isNotEmpty()) or searchRemoveRelatePromote
if (needSearchFilter) {
instance.searchAllResponseClass?.hookAfterMethod("getItemList") { p ->
val items = p.result as? List<Any?> ?: return@hookAfterMethod
p.result = items.filter { item ->
val videoCard = item?.getObjectField("cardItem_") ?: return@filter true
if (instance.searchVideoCardClass?.isInstance(videoCard) == false) {
if (searchRemoveRelatePromote) {
if (item.callMethodAs<Boolean>("hasCm")) return@filter false
if (item.callMethodAs<Boolean>("hasSpecial")) return@filter false
}
return@filter true
}
if (videoCard.getLongField("mid_") in searchFilterUid) return@filter false
if (videoCard.getObjectFieldAs<String>("author_") in searchFilterUpNames) return@filter false
if (searchFilterContentRegexMode) {
if (searchFilterContentRegexes.any { it.containsMatchIn(videoCard.getObjectFieldAs<String>("title_")) })
return@filter false
} else {
if (searchFilterContents.any { videoCard.getObjectFieldAs<String>("title_").contains(it) }) return@filter false
}
true
}
}
}
val needCommentFilter =
hidden && (commentFilterBlockAtComment || commentFilterContents.isNotEmpty() || commentFilterAtUid.isNotEmpty() || commentFilterAtUpNames.isNotEmpty() || targetCommentAuthorLevel != 0L)
if (needCommentFilter) {
val blockAtCommentSplitRegex = Regex("\\s+")
fun Any.validCommentAuthorLevel(): Boolean {
if (targetCommentAuthorLevel == 0L) return true
val authorLevel = getObjectField("member_")?.getObjectFieldAs<Long>("level_") ?: 6L
return authorLevel >= targetCommentAuthorLevel
}
fun Any.validCommentContent(): Boolean {
val content = getObjectField("content_") ?: return true
val commentMessage = content.getObjectFieldAs<String>("message_")
val contentIsToBlock = commentFilterContents.isNotEmpty() && if (commentFilterContentRegexMode) {
commentFilterContentRegexes.any { commentMessage.contains(it) }
} else {
commentFilterContents.any { commentMessage.contains(it) }
}
if (contentIsToBlock) return false
if (commentFilterBlockAtComment && commentMessage.trim()
.split(blockAtCommentSplitRegex).all { it.startsWith("@") }
) return false
if (commentFilterAtUpNames.isEmpty() && commentFilterAtUid.isEmpty()) return true
val atNameToMid = content.getObjectFieldAs<Map<String, Long>>("atNameToMid_")
return !(atNameToMid.keys.any { it in commentFilterAtUpNames } || atNameToMid.values.any { it in commentFilterAtUid })
}
fun Any.filterComment() = validCommentAuthorLevel() && validCommentContent()
"com.bapis.bilibili.main.community.reply.v1.MainListReply".from(mClassLoader)
?.hookAfterMethod("getRepliesList") { p ->
val l = p.result as? List<*> ?: return@hookAfterMethod
p.result = l.filter { it?.filterComment() ?: true }
}
"com.bapis.bilibili.main.community.reply.v1.ReplyInfo".from(mClassLoader)
?.hookAfterMethod("getRepliesList") { p ->
val l = p.result as? List<*> ?: return@hookAfterMethod
p.result = l.filter { it?.filterComment() ?: true }
}
}
if (!sPrefs.getBoolean("replace_story_video", false)) return
val disableBooleanValue = "com.bapis.bilibili.app.distribution.BoolValue".from(mClassLoader)?.callStaticMethod("getDefaultInstance") ?: return
"com.bapis.bilibili.app.distribution.setting.play.PlayConfig".from(mClassLoader)?.run {
replaceAllMethods("getLandscapeAutoStory") { disableBooleanValue }
replaceAllMethods("getShouldAutoStory") { disableBooleanValue }
}
}
private fun hookMossView() {
instance.viewUniteMossClass?.hookAfterMethod(
"executeView",
instance.viewUniteReqClass
) { param ->
param.result?.let {
handleViewReply(it, true)
}
}
instance.viewMossClass?.hookAfterMethod(
if (instance.useNewMossFunc) "executeView" else "view",
instance.viewReqClass
) { param ->
param.result?.let {
handleViewReply(it, false)
}
}
}
private fun handleViewReply(viewReply: Any, isUnite: Boolean) {
val aid = viewReply.callMethod("getArc")
?.callMethodAs("getAid") ?: -1L
val like = viewReply.callMethod("getReqUser")
?.callMethodAs("getLike") ?: -1
if (aid > 0 && like != -1) {
AutoLikeHook.detail = aid to like
}
BangumiPlayUrlHook.qnApplied.set(false)
// 视频详情页荣誉卡片
fun Any.isHonor() = removeHonor && callMethodAs("hasHonor")
// 视频详情页活动卡片(含会员购等), 区分于视频下方推荐处的广告卡片
fun Any.isActivityEntranceModule() = blockViewPageAds && callMethodAs("hasActivityEntranceModule")
// 视频详情页直播预约卡片
fun Any.isLiveOrder() = blockLiveOrder && callMethodAs("hasLiveOrder")
// 视频下方分集列表
fun Any.isUgcSeason() = removeUgcSeason && callMethodAs("hasUgcSeason")
fun Any.isLikeComment() = blockCommentGuide && callMethodAs("hasLikeComment")
fun MutableList<Any>.filter() = removeAll {
it.isActivityEntranceModule() || it.isHonor() || it.isLiveOrder() || it.isUgcSeason() || it.isLikeComment()
}
if (isUnite) {
if (blockViewPageAds) {
viewReply.callMethod("clearCm")
}
if (hidden && removeUpVipLabel) {
viewReply.callMethod("getOwner")?.callMethod("getVip")?.callMethod("clearLabel")
}
viewReply.callMethod("getTab")?.run {
callMethod("ensureTabModuleIsMutable")
val tabModuleList = callMethodAs<MutableList<Any>>("getTabModuleList")
if (blockVideoComment) {
tabModuleList.removeAll {
it.callMethodAs("hasReply")
}
}
if (!(blockCommentGuide || blockViewPageAds || removeHonor || removeUgcSeason)) return@run
tabModuleList.firstOrNull { it.callMethod("hasIntroduction") == true }?.let {
it.callMethodAs<Any>("getIntroduction").run {
callMethod("ensureModulesIsMutable")
callMethodAs<MutableList<Any>>("getModulesList").filter()
}
}
if (blockCommentGuide) {
tabModuleList.firstOrNull { it.callMethod("hasReply") == true }?.let { tabModule ->
tabModule.callMethod("getReply")?.callMethod("getReplyStyle")
?.callMethod("clearBadgeType")
}
}
}
return
}
if (unlockPlayLimit)
viewReply.callMethod("getConfig")?.callMethod("setShowListenButton", true)
if (blockCommentGuide) {
viewReply.runCatchingOrNull {
callMethod("getLikeCustom")
?.callMethod("clearLikeComment")
callMethod("getReplyPreface")
?.callMethod("clearBadgeType")
}
}
if (hidden && removeHonor) {
viewReply.callMethod("clearHonor")
}
if (hidden && removeUgcSeason) {
viewReply.callMethod("clearUgcSeason")
}
if (hidden && blockLiveOrder) {
viewReply.callMethod("clearLiveOrderInfo")
}
if (hidden && removeUpVipLabel) {
viewReply.callMethod("getOwnerExt")?.callMethod("getVip")?.callMethod("clearLabel")
}
}
}
@@ -0,0 +1,18 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.hookBeforeConstructor
import me.iacn.biliroaming.utils.sPrefs
class PublishToFollowingHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("disable_auto_select", false))
return
instance.publishToFollowingConfigClass?.hookBeforeConstructor(
Boolean::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType,
) { it.args[2]/*autoSelectOnce*/ = false }
}
}
@@ -0,0 +1,22 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.hookBeforeMethod
import me.iacn.biliroaming.utils.sPrefs
class QualityHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
sPrefs.getString("cn_server_accessKey", null) ?: return
Log.d("startHook: Quality")
"com.bilibili.lib.accountinfo.model.VipUserInfo".hookBeforeMethod(
mClassLoader,
"isEffectiveVip"
) {
Thread.currentThread().stackTrace.find { stack ->
stack.className.contains(".quality.")
} ?: return@hookBeforeMethod
it.result = true
}
}
}
@@ -0,0 +1,23 @@
package me.iacn.biliroaming.hook
import android.os.Bundle
import android.widget.TextView
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
class RewardAdHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("skip_reward_ad", false)) return
Log.d("startHook: RewardAd")
instance.rewardAdClass?.hookAfterMethod("onCreate", Bundle::class.java) { params ->
params.thisObject.setBooleanField(instance.rewardFlag(), true)
(params.thisObject.javaClass.declaredFields.firstOrNull {
it.type == TextView::class.java
}?.apply { isAccessible = true }?.get(params.thisObject) as? TextView)?.performClick()
}
}
}
@@ -0,0 +1,168 @@
@file:Suppress("DEPRECATION")
package me.iacn.biliroaming.hook
import android.annotation.SuppressLint
import android.net.http.SslError
import android.webkit.SslErrorHandler
import android.webkit.WebView
import me.iacn.biliroaming.utils.*
import org.apache.http.conn.scheme.HostNameResolver
import org.apache.http.conn.ssl.SSLSocketFactory
import java.net.Socket
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.*
class SSLHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
Log.d("startHook: Ssl")
val emptyTrustManagers = arrayOf(object : X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String) {
}
@SuppressLint("TrustAllX509TrustManager")
override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String) {
}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
@Suppress("unused", "UNUSED_PARAMETER")
fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
host: String
): List<X509Certificate> = emptyList()
})
"javax.net.ssl.TrustManagerFactory".hookBeforeMethod(
mClassLoader,
"getTrustManagers"
) { param ->
param.result = emptyTrustManagers
}
"javax.net.ssl.SSLContext".hookBeforeMethod(
mClassLoader,
"init",
"javax.net.ssl.KeyManager[]",
"javax.net.ssl.TrustManager[]",
SecureRandom::class.java
) { param ->
param.args[0] = null
param.args[1] = emptyTrustManagers
param.args[2] = null
}
"javax.net.ssl.HttpsURLConnection".hookBeforeMethod(
mClassLoader,
"setSSLSocketFactory",
javax.net.ssl.SSLSocketFactory::class.java
) { param ->
param.args[0] = "javax.net.ssl.SSLSocketFactory".findClass(mClassLoader).new()
}
"org.apache.http.conn.scheme.SchemeRegistry".findClassOrNull(mClassLoader)
?.hookBeforeMethod("register", "org.apache.http.conn.scheme.Scheme") { param ->
if (param.args[0].callMethodAs<String>("getName") == "https") {
param.args[0] = param.args[0].javaClass.new(
"https",
SSLSocketFactory.getSocketFactory(),
443
)
}
}
"org.apache.http.conn.ssl.HttpsURLConnection".findClassOrNull(mClassLoader)?.run {
hookBeforeMethod("setDefaultHostnameVerifier", HostnameVerifier::class.java) { param ->
param.args[0] = SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER
}
hookBeforeMethod("setHostnameVerifier", HostnameVerifier::class.java) { param ->
param.args[0] = SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER
}
}
"org.apache.http.conn.ssl.SSLSocketFactory".hookBeforeMethod(
mClassLoader,
"getSocketFactory"
) { param ->
param.result = SSLSocketFactory::class.java.new()
}
"org.apache.http.conn.ssl.SSLSocketFactory".findClassOrNull(mClassLoader)
?.hookAfterConstructor(
String::class.java,
KeyStore::class.java,
String::class.java,
KeyStore::class.java,
SecureRandom::class.java,
HostNameResolver::class.java
) { param ->
val algorithm = param.args[0] as? String
val keystore = param.args[1] as? KeyStore
val keystorePassword = param.args[2] as? String
val random = param.args[4] as? SecureRandom
@Suppress("UNCHECKED_CAST") val trustManagers =
emptyTrustManagers as Array<TrustManager>
val keyManagers = keystore?.let {
SSLSocketFactory::class.java.callStaticMethodAs<Array<KeyManager>>(
"createKeyManagers",
keystore,
keystorePassword
)
}
param.thisObject.setObjectField("sslcontext", SSLContext.getInstance(algorithm))
param.thisObject.getObjectField("sslcontext")
?.callMethod("init", keyManagers, trustManagers, random)
param.thisObject.setObjectField(
"socketfactory",
param.thisObject.getObjectField("sslcontext")?.callMethod("getSocketFactory")
)
}
"org.apache.http.conn.ssl.SSLSocketFactory".hookAfterMethod(
mClassLoader,
"isSecure",
Socket::class.java
) { param ->
param.result = true
}
"okhttp3.CertificatePinner".findClassOrNull(mClassLoader)?.run {
(runCatchingOrNull { getDeclaredMethod("findMatchingPins", String::class.java) }
?: declaredMethods.firstOrNull { it.parameterTypes.size == 1 && it.parameterTypes[0] == String::class.java && it.returnType == List::class.java })?.hookBeforeMethod { param ->
param.args[0] = ""
}
}
"android.webkit.WebViewClient".findClassOrNull(mClassLoader)?.run {
replaceMethod(
"onReceivedSslError",
WebView::class.java,
SslErrorHandler::class.java,
SslError::class.java
) { param ->
(param.args[1] as SslErrorHandler).proceed()
null
}
replaceMethod(
"onReceivedError",
WebView::class.java,
Int::class.javaPrimitiveType,
String::class.java,
String::class.java
) {
null
}
}
}
}
@@ -0,0 +1,243 @@
package me.iacn.biliroaming.hook
import android.app.Activity
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.SettingDialog
import me.iacn.biliroaming.utils.*
import java.lang.reflect.Constructor
import java.lang.reflect.Proxy
class SettingHook(classLoader: ClassLoader) : BaseHook(classLoader) {
private var startSetting = false
override fun startHook() {
Log.d("startHook: Setting")
instance.splashActivityClass?.hookBeforeMethod("onCreate", Bundle::class.java) { param ->
val self = param.thisObject as Activity
startSetting = self.intent.hasExtra(START_SETTING_KEY)
}
instance.mainActivityClass?.hookAfterMethod("onResume") { param ->
if (startSetting) {
startSetting = false
SettingDialog.show(param.thisObject as Activity)
}
}
instance.mainActivityClass?.hookBeforeMethod(
"onCreate",
Bundle::class.java
) { param ->
val bundle = param.args[0] as? Bundle
bundle?.remove("android:fragments")
}
instance.drawerClass?.hookAfterMethod(
"onCreateView",
LayoutInflater::class.java,
ViewGroup::class.java,
Bundle::class.java
) { param ->
val navSettingId = getId("nav_settings")
val nav =
param.thisObject.javaClass.declaredFields.first { it.type.name == "android.support.design.widget.NavigationView" }.name
(param.thisObject.getObjectField(nav)
?: param.result).callMethodAs<View>("findViewById", navSettingId)
.setOnLongClickListener {
SettingDialog.show(param.thisObject.callMethodAs<Activity>("getActivity"))
true
}
}
instance.homeCenters().forEach { (c, m) ->
c?.hookBeforeAllMethods(m) { param ->
@Suppress("UNCHECKED_CAST")
val list = param.args[1] as? MutableList<Any>
?: param.args[1]?.getObjectFieldOrNullAs<MutableList<Any>>("moreSectionList")
?: return@hookBeforeAllMethods
val itemList = list.lastOrNull()?.let {
if (it.javaClass != instance.menuGroupItemClass) it.getObjectFieldOrNullAs<MutableList<Any>>(
"itemList"
) else list
} ?: list
val item = makeSettingItem() ?: return@hookBeforeAllMethods
itemList.forEach {
if (try {
it.getIntField("id") == SETTING_ID
} catch (t: Throwable) {
it.getLongField("id") == SETTING_ID.toLong()
}
) return@hookBeforeAllMethods
}
itemList.add(item)
}
}
instance.settingRouterClass?.hookBeforeAllConstructors { param ->
if (param.args[1] != SETTING_URI) return@hookBeforeAllConstructors
val routerType = (param.method as Constructor<*>).parameterTypes[3]
param.args[3] = Proxy.newProxyInstance(
routerType.classLoader,
arrayOf(routerType)
) { _, method, _ ->
val returnType = method.returnType
Proxy.newProxyInstance(
returnType.classLoader,
arrayOf(returnType)
) { _, method2, args ->
when (method2.returnType) {
Boolean::class.javaPrimitiveType -> false
else -> {
if (method2.parameterTypes.isNotEmpty() &&
method2.parameterTypes[0].name == "android.app.Activity"
) {
val currentActivity = args[0] as Activity
SettingDialog.show(currentActivity)
}
null
}
}
}
}
}
// 8.97.0+: hook 菜单适配器 notify* 方法注入设置项
instance.mineAdapterClass?.let { hookAdapterFallback(it) }
}
private fun hookAdapterFallback(adapterClass: Class<*>) {
val menuGroupItemClass = instance.menuGroupItemClass ?: return
val dataField = adapterClass.declaredFields.firstOrNull {
List::class.java.isAssignableFrom(it.type)
} ?: return
dataField.isAccessible = true
fun injectSettingItem(data: MutableList<*>) {
if (data.isEmpty()) return
// 去重:检查 data 中所有 MenuGroup 的 itemList 是否已含设置项
for (group in data) {
group?.getObjectFieldOrNullAs<MutableList<Any>>("itemList")?.forEach { item ->
if (item.javaClass == menuGroupItemClass &&
(try {
item.getIntField("id") == SETTING_ID
} catch (_: Throwable) {
item.getLongField("id") == SETTING_ID.toLong()
})
) return
}
}
val targetList = data.lastOrNull()
?.getObjectFieldOrNullAs<MutableList<Any>>("itemList") ?: return
val item = makeSettingItem() ?: return
targetList.add(if(targetList.isEmpty()) 0 else targetList.lastIndex, item)
}
// hook RecyclerView.Adapter 多个通知方法代替单一 notifyDataSetChanged
// 8.97.0 的适配器使用 notifyItemRangeChanged 刷新数据
listOf(
"notifyDataSetChanged", "notifyItemRangeChanged",
"notifyItemRangeInserted", "notifyItemRangeRemoved", "notifyItemChanged"
).forEach { methodName ->
runCatching {
adapterClass.getMethod(methodName).hookBeforeMethod { param ->
if (!adapterClass.isInstance(param.thisObject)) return@hookBeforeMethod
val data = dataField.get(param.thisObject) as? MutableList<*>
?: return@hookBeforeMethod
injectSettingItem(data)
}
}
}
// 为注入的设置项绑定点击监听
// 8.97.0+ 为双层 RecyclerView:外层分组适配器 → 内层条目适配器
// 从外层 data 定位包含注入项的 MenuGroup,post 后遍历 View 树找到内层 RecyclerView
fun bindSettingClick(holder: Any, position: Int, adapter: Any) {
val data = dataField.get(adapter) as? List<*> ?: return
val group = data.getOrNull(position) ?: return
val itemList = group.getObjectFieldOrNullAs<List<*>>("itemList") ?: return
itemList.find {
it?.javaClass == menuGroupItemClass && it.getObjectField("uri") == SETTING_URI
} ?: return
val itemView = holder.getObjectFieldOrNullAs<View>("itemView") ?: return
itemView.post {
val rv = findFirstRecyclerView(itemView) ?: return@post
val innerAdapter = rv.callMethodAs<Any?>("getAdapter") ?: return@post
@Suppress("UNCHECKED_CAST")
val innerData = innerAdapter.javaClass.declaredFields
.firstOrNull { List::class.java.isAssignableFrom(it.type) }
?.also { it.isAccessible = true }
?.get(innerAdapter) as? List<Any> ?: return@post
val idx = innerData.indexOfFirst {
it.javaClass == menuGroupItemClass &&
it.getObjectField("uri") == SETTING_URI
}
if (idx < 0) return@post
val lm = rv.callMethodAs<Any?>("getLayoutManager") ?: return@post
val child = lm.callMethodAs<View?>("findViewByPosition", idx) ?: return@post
child.setOnClickListener {
val ctx = it.context
if (ctx is Activity) SettingDialog.show(ctx)
}
}
}
// 2-param onBindViewHolder (首次 bind、全量刷新)
adapterClass.methods.firstOrNull { m ->
m.name == "onBindViewHolder" && m.parameterTypes.size == 2
}?.hookAfterMethod { param ->
bindSettingClick(param.args[0], param.args[1] as Int, param.thisObject)
}
}
/**
* 递归遍历 View 树,查找第一个 RecyclerView 实例。
*/
private fun findFirstRecyclerView(root: View): View? {
if (rvClass?.isInstance(root) == true) return root
if (root !is ViewGroup) return null
for (i in 0 until root.childCount) {
findFirstRecyclerView(root.getChildAt(i))?.let { return it }
}
return null
}
private val rvClass: Class<*>? by lazy {
"androidx.recyclerview.widget.RecyclerView".findClassOrNull(mClassLoader)
?: "android.support.v7.widget.RecyclerView".findClassOrNull(mClassLoader)
}
companion object {
const val START_SETTING_KEY = "biliroaming_start_setting"
const val SETTING_URI = "bilibili://biliroaming"
const val SETTING_ID = 114514
fun makeSettingItem(): Any? {
val item = instance.menuGroupItemClass?.new() ?: return null
try {
item.setIntField("id", SETTING_ID)
} catch (_: Throwable) {
item.setLongField("id", SETTING_ID.toLong())
}
item.setObjectField("title", "哔哩漫游设置")
.setObjectField(
"icon",
"https://i0.hdslb.com/bfs/album/276769577d2a5db1d9f914364abad7c5253086f6.png"
)
.setObjectField("uri", SETTING_URI)
item.setIntField("visible", 1)
return item
}
}
}
@@ -0,0 +1,118 @@
package me.iacn.biliroaming.hook
import android.net.Uri
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.bv2av
import me.iacn.biliroaming.utils.getObjectField
import me.iacn.biliroaming.utils.hookAfterMethod
import me.iacn.biliroaming.utils.sPrefs
import me.iacn.biliroaming.utils.setObjectField
import java.net.HttpURLConnection
import java.net.URL
class ShareHook(classLoader: ClassLoader) : BaseHook(classLoader) {
private val contentUrlPattern = Regex("""[\s\S]*(https?://(?:bili2233\.cn|b23\.tv)/\S*)$""")
private fun String.resolveB23URL(): String {
val conn = URL(this).openConnection() as HttpURLConnection
conn.requestMethod = "GET"
conn.instanceFollowRedirects = false
conn.connect()
if (conn.responseCode == HttpURLConnection.HTTP_MOVED_TEMP) {
return conn.getHeaderField("Location")
}
return this
}
private fun transformUrl(url: String, transformAv: Boolean): String {
val target = Uri.parse(url)
val bv = if (transformAv) {
target.path?.split("/")?.firstOrNull { it.startsWith("BV") && it.length == 12 }
} else {
null
}
val av = bv?.let { "av${bv2av(bv)}" }
val newUrl = target.buildUpon()
if (av != null) {
newUrl.path(target.path!!.replace(bv, av))
}
val encodedQuery = target.encodedQuery
if (encodedQuery != null) {
val query = encodedQuery.split("&").map {
it.split("=")
}.filter {
it.size == 2
}.mapNotNull {
when {
it[0] == "p" || it[0] == "t" -> "${it[0]}=${it[1]}"
it[0] == "start_progress" -> "start_progress=${it[1]}&t=${it[1].toLong() / 1000}"
else -> null
}
}.joinToString("&", postfix = "&unique_k=2333")
newUrl.encodedQuery(query)
} else {
newUrl.appendQueryParameter("unique_k", "2333")
}
return newUrl.build().toString()
}
override fun startHook() {
val miniProgramEnabled = sPrefs.getBoolean("mini_program", false)
val purifyShareEnabled = sPrefs.getBoolean("purify_share", false)
if (!miniProgramEnabled && !purifyShareEnabled) return
Log.d("startHook: ShareHook")
instance.shareClickResultClass?.apply {
if (purifyShareEnabled) {
hookAfterMethod("getLink") { param ->
(param.result as? String)?.takeIf {
it.startsWith("https://bili2233.cn") || it.startsWith("http://bili2233.cn") || it.startsWith("https://b23.tv") || it.startsWith("http://b23.tv")
}?.let {
val targetUrl = Uri.parse(it).buildUpon().query("").build().toString()
param.result = targetUrl.resolveB23URL().also { r -> param.thisObject.setObjectField("link", r) }
}
}
hookAfterMethod("getContent") { param ->
val content = param.result as? String
content?.let {
contentUrlPattern.matchEntire(it)?.groups?.get(1)?.value
}?.let { contentUrl ->
val resolvedUrl = (param.thisObject.getObjectField("link")?.let { it as String } ?: contentUrl)
.let {
if (it.startsWith("https://bili2233.cn") || it.startsWith("http://bili2233.cn") || it.startsWith("https://b23.tv") || it.startsWith("http://b23.tv"))
it.resolveB23URL()
else it
}
param.result = content.replace(contentUrl, transformUrl(resolvedUrl, miniProgramEnabled)).also { r ->
param.thisObject.setObjectField("content", r)
}
}
}
}
if (!miniProgramEnabled) return@apply
// ShareMode Definition
// 1: PARAMS_TYPE_TEXT
// 2: PARAMS_TYPE_AUDIO
// 4: PARAMS_TYPE_VIDEO
// 5: PARAMS_TYPE_IMAGE
// 6 / 7: PARAMS_TYPE_MIN_PROGRAM
// 21: PARAMS_TYPE_PURE_IMAGE
// Others: PARAMS_TYPE_WEB
hookAfterMethod("getShareMode") { param ->
if (param.result == 6 || param.result == 7) {
param.result = 0
param.thisObject.apply {
getObjectField("title")?.takeIf { it == "哔哩哔哩" }?.let { title ->
setObjectField("title", getObjectField("content"))
setObjectField("content", "由哔哩漫游分享")
}
getObjectField("content")?.let { it as String }
?.takeIf { it.startsWith("已观看") }?.let { content ->
setObjectField("content", "$content\n由哔哩漫游分享")
}
}
}
}
}
}
}
@@ -0,0 +1,27 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.callMethod
import me.iacn.biliroaming.utils.callMethodOrNullAs
import me.iacn.biliroaming.utils.getObjectField
import me.iacn.biliroaming.utils.hookAfterAllConstructors
import me.iacn.biliroaming.utils.sPrefs
class SpeedHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
Log.d("startHook: SpeedHook")
val defaultPlaybackSpeed = sPrefs.getInt("default_speed", 100) / 100f
if (defaultPlaybackSpeed == 1f) return
instance.playSpeedManager?.hookAfterAllConstructors {
for (f in it.thisObject.javaClass.declaredFields) {
val o = it.thisObject.getObjectField(f.name)
val v = o?.callMethodOrNullAs<Float?>("getValue") ?: continue
if (v != 1f) continue
o.callMethod("setValue", defaultPlaybackSpeed)
Log.toast("已设置 $defaultPlaybackSpeed 倍速")
break
}
}
}
}
@@ -0,0 +1,145 @@
package me.iacn.biliroaming.hook
import android.content.res.Configuration
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
import java.io.File
class SplashHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
// 解除 AbstractComposeView 的 addView 限制,使其恢复普通 ViewGroup 行为
if (!composeAddViewPatched) {
try {
instance.composeGuardMethod?.replaceMethod { null }
} catch (_: Throwable) {}
composeAddViewPatched = true
}
val customSplash = sPrefs.getBoolean("custom_splash", false)
val customSplashLogo = sPrefs.getBoolean("custom_splash_logo", false)
val fullSplash = sPrefs.getBoolean("full_splash", false)
val autoDarkSplash = sPrefs.getBoolean("auto_dark_splash", false)
if (
!customSplash &&
!customSplashLogo &&
!fullSplash &&
!autoDarkSplash
) return
Log.d("startHook: Splash")
instance.splashInfoClass?.hookAfterMethod(
"getMode"
) { param -> if (fullSplash) param.result = "full" }
instance.brandSplashClass?.hookAfterMethod(
"onViewCreated",
View::class.java,
Bundle::class.java
) { param ->
val view = param.args[0] as View
// auto_dark_splash: 优先设置 splash_container 背景,回退到 root view
if (autoDarkSplash) {
val bgColor = if (view.resources.configuration.uiMode.and(Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES)
Color.BLACK else Color.WHITE
val containerId = getId("splash_container")
val container = if (containerId != 0) view.findViewById<View>(containerId) else null
(container ?: view).setBackgroundColor(bgColor)
}
// 检测渲染路径:brand_splash 存在于 XML 布局,不存在于 ComposeView
val brandId = getId("brand_splash")
val isXmlPath = brandId != 0 && view.findViewById<ImageView>(brandId) != null
if (isXmlPath) {
applyXmlPath(view, brandId, customSplash, customSplashLogo)
} else {
applyComposeOverlay(view, customSplash, customSplashLogo)
}
}
}
private fun applyXmlPath(view: View, brandId: Int, customSplash: Boolean, customSplashLogo: Boolean) {
if (customSplash) {
val brandSplash = view.findViewById<ImageView>(brandId)!!
val fullId = getId("full_brand_splash")
val full = if (fullId != 0) view.findViewById<ImageView>(fullId) else null
val splashImage = File(currentContext.filesDir, SPLASH_IMAGE)
if (splashImage.exists()) {
val uri = Uri.fromFile(splashImage)
brandSplash.setImageURI(uri)
full?.setImageURI(uri)
} else {
brandSplash.alpha = .0f
full?.alpha = .0f
}
}
if (customSplashLogo) {
val logoId = getId("brand_logo")
val brandLogo = view.findViewById<ImageView>(logoId) ?: return
val logoImage = File(currentContext.filesDir, LOGO_IMAGE)
if (logoImage.exists())
brandLogo.setImageURI(Uri.fromFile(logoImage))
else
brandLogo.alpha = .0f
}
}
/**
* Compose 渲染路径:addView 限制已解除,直接用 FrameLayout 包裹
* overlay 添加到 ComposeView 自身,随 ComposeView 自动销毁。
*/
private fun applyComposeOverlay(composeView: View, customSplash: Boolean, customSplashLogo: Boolean) {
val ctx = composeView.context
val logoH = composeView.resources.displayMetrics.heightPixels / 8
val splashImage = File(currentContext.filesDir, SPLASH_IMAGE)
val logoImage = File(currentContext.filesDir, LOGO_IMAGE)
val overlay = FrameLayout(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
if (customSplash) {
overlay.addView(ImageView(ctx).apply {
scaleType = ImageView.ScaleType.CENTER_CROP
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
if (splashImage.exists()) setImageURI(Uri.fromFile(splashImage))
else visibility = View.GONE
})
}
if (customSplashLogo) {
overlay.addView(ImageView(ctx).apply {
scaleType = ImageView.ScaleType.FIT_CENTER
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
logoH,
Gravity.BOTTOM
)
if (logoImage.exists()) setImageURI(Uri.fromFile(logoImage))
else visibility = View.GONE
})
}
(composeView as ViewGroup).addView(overlay)
}
companion object {
const val SPLASH_IMAGE = "biliroaming_splash"
const val LOGO_IMAGE = "biliroaming_logo"
private var composeAddViewPatched = false
}
}
@@ -0,0 +1,98 @@
package me.iacn.biliroaming.hook
import android.app.Activity
import android.app.Instrumentation
import android.content.ComponentName
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import me.iacn.biliroaming.BiliBiliPackage
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.hookBeforeAllMethods
import me.iacn.biliroaming.utils.hookBeforeMethod
import me.iacn.biliroaming.utils.packageName
import me.iacn.biliroaming.utils.sPrefs
import me.iacn.biliroaming.utils.toJSONObject
import kotlin.math.floor
class StartActivityHook(classLoader: ClassLoader) : BaseHook(classLoader) {
private fun fixIntentUri(original: Uri): Uri {
val fixedUri = Uri.parse(original.toString().replace("bilibili://story/", "bilibili://united_video/")).buildUpon()
.clearQuery()
.appendQueryParameter("from_spmid", original.getQueryParameter("from_spmid"))
.appendQueryParameter("aid", original.path?.split("/")?.last() ?: "")
.appendQueryParameter("bvid", "")
.build()
return fixedUri
}
override fun startHook() {
"tv.danmaku.bili.ui.intent.IntentHandlerActivity".hookBeforeMethod(mClassLoader, "onCreate", Bundle::class.java) { param ->
val a = param.thisObject as Activity
val data = a.intent.data ?: return@hookBeforeMethod
a.intent.data = data.buildUpon().encodedQuery(data.encodedQuery?.replace("&-Arouter=story", "")?.replace("&-Atype=story", "")).build()
}
Instrumentation::class.java.hookBeforeAllMethods("execStartActivity") { param ->
val intent = param.args[4] as? Intent ?: return@hookBeforeAllMethods
val uri = intent.dataString ?: return@hookBeforeAllMethods
if (sPrefs.getBoolean(
"replace_story_video",
false
) && uri.startsWith("bilibili://story/")
) {
if (instance.hasUnitedVideoActivity) {
intent.data?.let {
try {
val cid = intent.data?.getQueryParameter("player_preload").toJSONObject().getLong("cid")
intent.data = fixIntentUri(Uri.parse(intent.dataString))
// fix extra
val pre = Uri.parse(intent.dataString).buildUpon().clearQuery().build().toString()
val aid = pre.split("/").last().toLong()
intent.removeExtra("player_preload")
intent.putExtra("player_preload", floor(Math.random()*1000000000).toInt().toString())
intent.putExtra("blrouter.targeturl", pre)
intent.putExtra("blrouter.pagename", "bilibili://united_video/")
intent.putExtra("jumpFrom", 7)
intent.putExtra("", aid)
intent.putExtra("aid", aid)
intent.putExtra("cid", cid)
intent.putExtra("bvid", "")
intent.putExtra("from", 7)
intent.putExtra("blrouter.targeturl", pre)
intent.putExtra("blrouter.matchrule", "bilibili://united_video/")
// fix component
intent.component = ComponentName(
intent.component?.packageName ?: packageName,
"com.bilibili.ship.theseus.detail.UnitedBizDetailsActivity"
)
} catch (e: Exception) {
Log.e("replaceStoryVideo fix intent failed!!!")
Log.e(e)
}
}
return@hookBeforeAllMethods
}
// 兼容旧版
intent.component = ComponentName(
intent.component?.packageName ?: packageName,
"com.bilibili.video.videodetail.VideoDetailsActivity"
)
intent.data = Uri.parse(uri.replace("bilibili://story/", "bilibili://video/"))
}
if (sPrefs.getBoolean("force_browser", false)) {
if (intent.component?.className?.endsWith("MWebActivity") == true &&
intent.data?.authority?.matches(whileListDomain) == false) {
Log.d("force_browser ${intent.data?.authority}")
param.args[4] = Intent(Intent.ACTION_VIEW).apply {
data = intent.data
}
}
}
}
}
companion object {
val whileListDomain = Regex(""".*bilibili\.com|.*b23\.tv""")
}
}
@@ -0,0 +1,66 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.from
import me.iacn.biliroaming.utils.hookBeforeMethod
import me.iacn.biliroaming.utils.sPrefs
class StoryPlayerAdHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
val purifyTags = sPrefs.getStringSet("purify_story_video_ad_tags", emptySet()) ?: emptySet()
if (purifyTags.isEmpty()) return
Log.d("startHook: StoryPlayerAd, purifyTags: $purifyTags")
instance.storyPagerPlayerClass?.hookBeforeMethod(
instance.addVideo(), List::class.java) { params ->
val storyDetailList = params.args[0] as? MutableList<*> ?: return@hookBeforeMethod
val toRemove = mutableListOf<Any>()
storyDetailList.forEach {
val storyDetail = it!!::class.java
val getCartIconInfoMethod = storyDetail.getDeclaredMethod("getCartIconInfo")
val isAdMethod = storyDetail.getDeclaredMethod("isAd")
getCartIconInfoMethod.isAccessible = true
isAdMethod.isAccessible = true
val isAd = isAdMethod.invoke(it) as? Boolean
var cartInfoText: String? = null
val cartIconInfo = getCartIconInfoMethod.invoke(it)
if (cartIconInfo != null) {
val cartClass = cartIconInfo.javaClass
val getEntryTextMethod = cartClass.getDeclaredMethod("getEntryText")
getEntryTextMethod.isAccessible = true
cartInfoText = getEntryTextMethod.invoke(cartIconInfo) as? String
}
val shouldRemove = when {
"ad" in purifyTags && isAd == true -> true
"short" in purifyTags && cartInfoText == "短剧" -> true
"shopping" in purifyTags && cartInfoText == "购物" -> true
"tv" in purifyTags && cartInfoText == "电视剧" -> true
"doc" in purifyTags && cartInfoText == "纪录片" -> true
"ent" in purifyTags && cartInfoText == "娱乐" -> true
"movie" in purifyTags && cartInfoText == "电影" -> true
"music" in purifyTags && cartInfoText == "音乐" -> true
"topic" in purifyTags && cartInfoText == "话题" -> true
else -> false
}
if (shouldRemove) {
toRemove.add(it)
}
}
storyDetailList.removeAll(toRemove.toSet())
val blockedCount = sPrefs.getInt("purify_story_video_ad_blocked_count", 0)
sPrefs.edit().putInt("purify_story_video_ad_blocked_count", blockedCount + toRemove.size).apply()
}
}
}
@@ -0,0 +1,464 @@
package me.iacn.biliroaming.hook
import android.graphics.*
import android.net.Uri
import android.text.Layout
import android.text.SpannableString
import android.text.StaticLayout
import android.text.TextPaint
import android.text.style.AbsoluteSizeSpan
import android.text.style.LineBackgroundSpan
import android.text.style.MaskFilterSpan
import android.text.style.StyleSpan
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import me.iacn.biliroaming.*
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.hook.BangumiSeasonHook.Companion.lastSeasonInfo
import me.iacn.biliroaming.hook.ProtoBufHook.Companion.removeCmdDms
import me.iacn.biliroaming.network.BiliRoamingApi
import me.iacn.biliroaming.utils.*
import org.json.JSONArray
import java.io.File
import java.lang.reflect.Method
import java.lang.reflect.Proxy
import kotlin.math.ceil
import kotlin.math.roundToInt
class SubtitleHook(classLoader: ClassLoader) : BaseHook(classLoader) {
companion object {
val fontFile by lazy { File(currentContext.getExternalFilesDir(null), "subtitle.font") }
val backgroundSpan = { backgroundColor: Int, textSize: Int ->
LineBackgroundSpan { canvas, paint, left, right, top, _, bottom, text, start, end, _ ->
val ts = paint.textSize
paint.textSize = textSize.toFloat()
val width = paint.measureText(text, start, end).roundToInt()
val textLeft = (right + left - width) / 2
val color = paint.color
val rect = Rect()
rect.set(textLeft, top, textLeft + width, bottom)
paint.color = backgroundColor
canvas.drawRect(rect, paint)
paint.textSize = ts
paint.color = color
}
}
fun subtitleStylizeRunner(
subtitle: SpannableString,
start: Int,
end: Int,
flags: Int,
blurSolid: Int,
fontColor: String,
fontSize: Int,
bgColor: String,
strokeColor: String,
strokeWidth: Float,
fixBreak: Boolean
) {
val subtitleBlurSolid = blurSolid.toString() + "f"
val fc = Color.parseColor("#$fontColor")
val sc = Color.parseColor("#$strokeColor")
if (fixBreak)
(start until end).forEach { i ->
subtitle.setSpan(
StrokeSpan(fc, sc, strokeWidth),
i,
i + 1,
flags
)
}
else
subtitle.setSpan(StrokeSpan(fc, sc, strokeWidth), start, end, flags)
subtitle.setSpan(
AbsoluteSizeSpan(fontSize, false),
start,
end,
flags
)
subtitle.setSpan(
StyleSpan(Typeface.BOLD),
start,
end,
flags
)
if (blurSolid != 0) {
subtitle.setSpan(
MaskFilterSpan(
BlurMaskFilter(
subtitleBlurSolid.toFloat(),
BlurMaskFilter.Blur.SOLID
)
),
start,
end,
flags
)
}
// should be drawn the last
subtitle.setSpan(
backgroundSpan(Color.parseColor("#$bgColor"), fontSize),
start,
end,
flags
)
}
}
private val hidden = sPrefs.getBoolean("hidden", false)
private val removeCmdDms = sPrefs.getBoolean("remove_video_cmd_dms", false)
private val mainFunc by lazy { sPrefs.getBoolean("main_func", false) }
private val generateSubtitle by lazy { sPrefs.getBoolean("auto_generate_subtitle", false) }
private val addCloseSubtitle by lazy { mainFunc && getVersionCode(packageName) >= 6750300 }
private val customSubtitle by lazy { sPrefs.getBoolean("custom_subtitle", false) }
private val removeBg by lazy { sPrefs.getBoolean("subtitle_remove_bg", true) }
private val boldText by lazy { sPrefs.getBoolean("subtitle_bold", true) }
private val fontSizePortrait by lazy { sPrefs.getInt("subtitle_font_size_portrait", 0).sp }
private val fontSizeLandscape by lazy { sPrefs.getInt("subtitle_font_size_landscape", 0).sp }
private val fillColor by lazy {
sPrefs.getString("subtitle_font_color2", null)
?.runCatchingOrNull { Color.parseColor("#$this") } ?: Color.WHITE
}
private val strokeColor by lazy {
sPrefs.getString("subtitle_stroke_color", null)
?.runCatchingOrNull { Color.parseColor("#$this") } ?: Color.BLACK
}
private val strokeWidth by lazy {
sPrefs.getFloat("subtitle_stroke_width", 5.0F)
}
private val offset by lazy { sPrefs.getInt("subtitle_offset", 0) }
private val closeText by lazy {
currentContext.getString(getResId("Player_option_subtitle_lan_doc_nodisplay", "string"))
}
private var subtitleFont: Typeface? = null
override fun startHook() {
if (customSubtitle)
if (instance.cronCanvasClass == null) {
hookSubtitleStyle()
} else {
hookSubtitleStyleNew()
}
if (mainFunc || generateSubtitle)
hookSubtitleList()
hookDmViewAsync()
}
private fun hookDmViewAsync() {
if (!(mainFunc || generateSubtitle || (hidden && removeCmdDms))) return
instance.dmMossClass?.hookBeforeMethod(
"dmView",
instance.dmViewReqClass,
instance.mossResponseHandlerClass
) { param ->
val dmViewReq = param.args[0]
param.args[1] = param.args[1].mossResponseHandlerReplaceProxy { dmViewReply ->
if (hidden && removeCmdDms) {
dmViewReply?.removeCmdDms()
}
if (mainFunc || generateSubtitle) {
dmViewReply.hookSubtitleList(dmViewReq)
} else null
}
}
}
private fun hookSubtitleStyle() {
instance.chronosSwitchClass?.hookAfterConstructor { param ->
param.thisObject.javaClass.declaredFields.forEach {
if (it.type == Boolean::class.javaObjectType) {
param.thisObject.setObjectField(it.name, false)
}
}
}
android.text.SpannableString::class.java.hookBeforeMethod(
"setSpan",
Object::class.java,
Int::class.java,
Int::class.java,
Int::class.java
) { param ->
if (instance.subtitleSpanClass?.isInstance(param.args[0]) != true) return@hookBeforeMethod
val (start, end, flags) = listOf(
param.args[1] as Int,
param.args[2] as Int,
param.args[3] as Int
)
(param.thisObject as SpannableString).run {
subtitleStylizeRunner(
this, start, end, flags,
sPrefs.getInt("subtitle_blur_solid", 1),
sPrefs.getString(
"subtitle_font_color2",
"FFFFFFFF"
)!!,
sPrefs.getInt("subtitle_font_size", 30),
sPrefs.getString(
"subtitle_background_color",
"20000000"
)!!,
sPrefs.getString("subtitle_stroke_color", "00000000")!!,
sPrefs.getFloat("subtitle_stroke_width", 0F),
sPrefs.getBoolean("subtitle_fix_break", false)
)
param.result = null
}
}
}
private fun hookSubtitleStyleNew() {
if (offset != 0) {
arrayOf(instance.subtitleConfigGetClass, instance.subtitleConfigChangeClass).forEach {
it?.hookBeforeMethod("setBottomMargin", Float::class.javaObjectType) { param ->
param.args[0] = (param.args[0] as Float) + offset
}
}
}
val cronCanvasClass = instance.cronCanvasClass ?: return
if (removeBg) {
cronCanvasClass.replaceMethod(
"drawPath",
Path::class.java,
Boolean::class.javaPrimitiveType
) { null }
}
val paintField = cronCanvasClass.getDeclaredField("paint")
.apply { isAccessible = true }
val fillColorField = cronCanvasClass.getDeclaredField("fillColor")
.apply { isAccessible = true }
val strokeColorField = cronCanvasClass.getDeclaredField("strokeColor")
.apply { isAccessible = true }
val maxWidthField = cronCanvasClass.getDeclaredField("maxWidth")
.apply { isAccessible = true }
val staticLayoutField = cronCanvasClass.getDeclaredField("staticLayout")
.apply { isAccessible = true }
val alignmentField = cronCanvasClass.getDeclaredField("alignment")
.apply { isAccessible = true }
val measureTextFromLayoutMethod = cronCanvasClass.getDeclaredMethod(
"measureTextFromLayout", StaticLayout::class.java
).apply { isAccessible = true }
MainScope().launch(Dispatchers.IO) {
subtitleFont = if (fontFile.isFile) {
Typeface.createFromFile(fontFile)
} else null
}
cronCanvasClass.hookBeforeMethod(
"measureTextImpl",
String::class.java
) { param ->
val cronCanvas = param.thisObject
val maxWidth = maxWidthField.getFloat(cronCanvas)
if (maxWidth != 0.0F) {
val paint = paintField.get(cronCanvas) as TextPaint
paint.strokeWidth = strokeWidth
paint.isFakeBoldText = boldText
subtitleFont?.let { paint.typeface = it }
if (currentIsLandscape && fontSizeLandscape > 0) {
paint.textSize = fontSizeLandscape.toFloat()
} else if (!currentIsLandscape && fontSizePortrait > 0) {
paint.textSize = fontSizePortrait.toFloat()
}
val text = param.args[0] as String
val alignment = alignmentField.get(cronCanvas) as Layout.Alignment
val staticLayout = staticLayoutField.get(cronCanvas) as? StaticLayout
if (staticLayout != null && staticLayout.text == text) {
param.result = measureTextFromLayoutMethod(null, staticLayout)
} else {
val wantWidth = if (maxWidth <= 0.0F) Int.MAX_VALUE
else maxWidth.toInt().coerceAtMost(Int.MAX_VALUE)
var layout = StaticLayout.Builder
.obtain(text, 0, text.length, paint, wantWidth)
.setAlignment(alignment)
.setIncludePad(false)
.build()
val lineMaxWidth = IntRange(0, layout.lineCount - 1).maxOfOrNull {
ceil(layout.getLineWidth(it)).toInt()
} ?: 0
layout = StaticLayout.Builder
.obtain(text, 0, text.length, paint, lineMaxWidth)
.setAlignment(alignment)
.setIncludePad(false)
.build()
staticLayoutField.set(cronCanvas, layout)
param.result = measureTextFromLayoutMethod(null, layout)
}
}
}
cronCanvasClass.hookBeforeMethod(
"drawText",
String::class.java,
Float::class.javaPrimitiveType,
Float::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType
) { param ->
val cronCanvas = param.thisObject
val maxWidth = maxWidthField.getFloat(cronCanvas)
if (maxWidth != 0.0F) {
fillColorField.setInt(cronCanvas, fillColor)
strokeColorField.setInt(cronCanvas, strokeColor)
param.args[3] = true
param.invokeOriginalMethod()
param.args[3] = false
}
}
}
private fun hookSubtitleList() {
instance.dmMossClass?.hookAfterMethod(
if (instance.useNewMossFunc) "executeDmView" else "dmView",
instance.dmViewReqClass,
) { param ->
param.result.hookSubtitleList(param.args[0])?.let { param.result = it }
}
if (!generateSubtitle) return
instance.biliCallClass?.hookBeforeMethod(
instance.setParser(), instance.parserClass
) { param ->
val url = param.thisObject.getObjectField(instance.biliCallRequestField())
?.getObjectField(instance.urlField())?.toString()
if (url?.contains("zh_converter=t2cn") != true)
return@hookBeforeMethod
val parser = param.args[0]
param.args[0] = Proxy.newProxyInstance(
parser.javaClass.classLoader,
arrayOf(instance.parserClass)
) { _, m, args ->
val dictReady = if (!SubtitleHelper.dictExist) {
SubtitleHelper.downloadDict()
} else true
val converted = if (dictReady) {
runCatching {
val responseText = args[0].callMethodAs<String>(instance.string())
SubtitleHelper.convert(responseText)
}.onFailure {
Log.e(it)
}.getOrNull()
?: SubtitleHelper.errorResponse(XposedInit.moduleRes.getString(R.string.subtitle_convert_failed))
} else SubtitleHelper.errorResponse(XposedInit.moduleRes.getString(R.string.subtitle_dict_download_failed))
val mediaType = instance.mediaTypeClass
?.callStaticMethod(
instance.get(),
"application/json; charset=UTF-8"
) ?: return@newProxyInstance m(parser, *args)
val responseBody = instance.responseBodyClass
?.callStaticMethod(
instance.create(),
mediaType,
converted
) ?: return@newProxyInstance m(parser, *args)
m(parser, responseBody)
}
}
}
private fun Any?.hookSubtitleList(originalReq: Any): Any? {
val originalReply = this
val parseDmViewReply = { r: Any? ->
r?.let { DmViewReply.parseFrom(it.callMethodAs<ByteArray>("toByteArray")) }
}
val extraSubtitles = mutableListOf<SubtitleItem>()
val oid = originalReq.callMethod("getOid").toString()
val tryThailand = lastSeasonInfo.containsKey(oid) && ((
lastSeasonInfo.containsKey("area")
&& lastSeasonInfo["area"] == "th") ||
(lastSeasonInfo.containsKey("watch_platform")
&& lastSeasonInfo["watch_platform"] == "1"
&& (originalReply == null || originalReply.callMethod("getSubtitle")
?.callMethod("getSubtitlesCount") == 0)))
if (mainFunc && tryThailand) {
val subtitles = if (lastSeasonInfo.containsKey("sb$oid")) {
JSONArray(lastSeasonInfo["sb$oid"])
} else {
val result = BiliRoamingApi.getThailandSubtitles(
lastSeasonInfo[oid] ?: lastSeasonInfo["epid"]
)?.toJSONObject()
if (result != null && result.optInt("code") == 0) {
result.optJSONObject("data")
?.optJSONArray("subtitles").orEmpty()
} else JSONArray()
}
if (subtitles.length() != 0) {
extraSubtitles += subtitles.toSubtitles()
}
// Disable danmaku for Area Th
originalReply?.run {
callMethod("setClosed", true)
callMethod("setInputPlaceholder", "泰区不支持")
}
}
val fromPGC = originalReq.callMethodAs<String>("getSpmid").contains("pgc")
val dmViewReply = if (generateSubtitle || (addCloseSubtitle && fromPGC)) {
parseDmViewReply(originalReply)
} else null
if (generateSubtitle) {
val subtitles = mutableListOf<SubtitleItem>()
dmViewReply?.subtitle?.subtitlesList?.let { subtitles += it }
subtitles += extraSubtitles
if (subtitles.map { it.lan }.let { "zh-Hant" in it && "zh-CN" !in it }) {
val origSub = subtitles.first { it.lan == "zh-Hant" }
val targetSubUrl = Uri.parse(origSub.subtitleUrl).buildUpon()
.appendQueryParameter("zh_converter", "t2cn")
.build().toString()
subtitleItem {
lan = "zh-CN"
lanDoc = "简中(生成)"
lanDocBrief = "简中"
subtitleUrl = targetSubUrl
id = origSub.id + 1
idStr = id.toString()
}.let { extraSubtitles += it }
}
}
if (addCloseSubtitle && fromPGC
&& (!dmViewReply?.subtitle?.subtitlesList.isNullOrEmpty() || extraSubtitles.isNotEmpty())
) {
subtitleItem {
lan = "nodisplay"
lanDoc = closeText
}.let { extraSubtitles += it }
}
if (extraSubtitles.isNotEmpty()) {
val newRes = (dmViewReply ?: parseDmViewReply(originalReply)
?: dmViewReply {}).copy {
subtitle = subtitle.copy {
subtitles += extraSubtitles
}
d = tryThailand
inputPlaceHolder = "泰区不支持"
}
return originalReply?.javaClass?.callStaticMethod("parseFrom", newRes.toByteArray())
}
return null
}
private fun JSONArray.toSubtitles(): List<SubtitleItem> {
val subList = mutableListOf<SubtitleItem>()
for (subtitle in this) {
subtitleItem {
id = subtitle.optLong("id")
idStr = subtitle.optLong("id").toString()
subtitleUrl = subtitle.optString("url")
lan = subtitle.optString("key")
lanDoc = subtitle.optString("title")
}.let { subList.add(it) }
}
return subList
}
}
@@ -0,0 +1,27 @@
package me.iacn.biliroaming.hook
import android.app.Activity
import android.os.Bundle
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.hookAfterMethod
import me.iacn.biliroaming.utils.sPrefs
/**
* Created by iAcn on 2019/12/15
* Email i@iacn.me
*/
class TeenagersModeHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("teenagers_mode_dialog", false)) return
Log.d("startHook: TeenagersMode")
instance.teenagersModeDialogActivityClass?.hookAfterMethod(
"onCreate", Bundle::class.java
) { param ->
val activity = param.thisObject as Activity
activity.finish()
Log.d("Teenagers mode dialog has been closed")
Log.toast("已关闭青少年模式对话框")
}
}
}
@@ -0,0 +1,15 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.hookBeforeMethod
import me.iacn.biliroaming.utils.replaceMethod
import me.iacn.biliroaming.utils.sPrefs
class TryWatchVipQualityHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("disable_try_watch_vip_quality", false)) return
instance.vipQualityTrialService?.replaceMethod(instance.canTrialMethod()) { false }
}
}
@@ -0,0 +1,88 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.UposReplaceHelper.enableLivePcdnBlock
import me.iacn.biliroaming.utils.UposReplaceHelper.enablePcdnBlock
import me.iacn.biliroaming.utils.UposReplaceHelper.enableUposReplace
import me.iacn.biliroaming.utils.UposReplaceHelper.forceUpos
import me.iacn.biliroaming.utils.UposReplaceHelper.gotchaRegex
import me.iacn.biliroaming.utils.UposReplaceHelper.initVideoUposList
import me.iacn.biliroaming.utils.UposReplaceHelper.isNeedReplaceVideoUpos
import me.iacn.biliroaming.utils.UposReplaceHelper.isOverseaUpos
import me.iacn.biliroaming.utils.UposReplaceHelper.isPCdnUpos
import me.iacn.biliroaming.utils.UposReplaceHelper.liveUpos
import me.iacn.biliroaming.utils.UposReplaceHelper.replaceUpos
import me.iacn.biliroaming.utils.UposReplaceHelper.videoUposBackups
import me.iacn.biliroaming.utils.from
import me.iacn.biliroaming.utils.getObjectFieldOrNull
import me.iacn.biliroaming.utils.getObjectFieldOrNullAs
import me.iacn.biliroaming.utils.hookBeforeConstructor
import me.iacn.biliroaming.utils.hookBeforeMethod
import me.iacn.biliroaming.utils.setObjectField
class UposReplaceHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!enableUposReplace || !(forceUpos || enablePcdnBlock || enableLivePcdnBlock)) return
Log.d("startHook: UposReplaceHook")
"tv.danmaku.ijk.media.player.IjkMediaAsset\$MediaAssertSegment\$Builder".from(mClassLoader)
?.run {
hookBeforeConstructor(String::class.java, Int::class.javaPrimitiveType) { param ->
val baseUrl = param.args[0] as String
if (baseUrl.contains("live-bvc")) {
if (enableLivePcdnBlock && !baseUrl.contains(gotchaRegex)) {
param.args[0] = baseUrl.replaceUpos(liveUpos)
}
} else if (baseUrl.isNeedReplaceVideoUpos()) {
param.args[0] = baseUrl.replaceUpos()
}
}
if (!(enablePcdnBlock || forceUpos)) return@run
hookBeforeMethod("setBackupUrls", MutableCollection::class.java) { param ->
val mediaAssertSegment = param.thisObject.getObjectFieldOrNull("target")
val baseUrl =
mediaAssertSegment?.getObjectFieldOrNullAs<String>("url").orEmpty()
if (baseUrl.isEmpty()) return@hookBeforeMethod
val backupUrls = if (param.args[0] == null) {
if (baseUrl.contains("live-bvc")) return@hookBeforeMethod else {
emptyList<String>()
}
} else {
@Suppress("UNCHECKED_CAST")
(param.args[0] as List<String>).filter { !it.isPCdnUpos() }
.takeIf { backupUrls ->
backupUrls.isEmpty() || !backupUrls.any { it.contains("live-bvc") }
} ?: return@hookBeforeMethod
}
reconstructBackupUposList(
baseUrl, backupUrls, mediaAssertSegment
).takeIf { it.isNotEmpty() }?.let {
param.args[0] = it
}
}
}
}
override fun lateInitHook() {
initVideoUposList(mClassLoader)
}
private fun reconstructBackupUposList(
baseUrl: String, backupUrls: List<String>, mediaAssertSegment: Any?
): List<String> {
val rawUrl = backupUrls.firstOrNull() ?: baseUrl
return if (baseUrl.isPCdnUpos()) {
if (backupUrls.isNotEmpty()) {
mediaAssertSegment?.setObjectField("url", rawUrl.replaceUpos())
listOf(rawUrl.replaceUpos(videoUposBackups[0], rawUrl.isOverseaUpos()), baseUrl)
} else emptyList()
} else {
if (enablePcdnBlock || forceUpos || backupUrls.isEmpty() || rawUrl.isOverseaUpos()) {
listOf(
rawUrl.replaceUpos(videoUposBackups[0]), rawUrl.replaceUpos(videoUposBackups[1])
)
} else emptyList()
}
}
}
@@ -0,0 +1,54 @@
package me.iacn.biliroaming.hook
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.*
class VideoQualityHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("main_func", false)) return
val halfScreenQuality = sPrefs.getString("half_screen_quality", "0")?.toInt() ?: 0
val fullScreenQuality = sPrefs.getString("full_screen_quality", "0")?.toInt() ?: 0
if (halfScreenQuality != 0) {
instance.playerPreloadHolderClass?.replaceAllMethods(instance.getPreload()) { null }
instance.playerQualityServices().forEach { (clazz, getDefaultQnThumb) ->
clazz?.replaceAllMethods(getDefaultQnThumb) { halfScreenQuality }
}
}
if (fullScreenQuality != 0) {
instance.playerSettingHelperClass?.replaceMethod(instance.getDefaultQn()) { fullScreenQuality }
}
if (halfScreenQuality != 0 || fullScreenQuality != 0) {
instance.autoSupremumQualityClass?.hookBeforeConstructor(
*Array(6) { Int::class.javaPrimitiveType }
) { param ->
if (halfScreenQuality != 0) {
param.args[0] = halfScreenQuality // loginHalf
param.args[3] = halfScreenQuality // unloginHalf
param.args[4] = halfScreenQuality // unloginFull
param.args[5] = halfScreenQuality // unloginMobileFull
}
if (fullScreenQuality != 0) {
param.args[1] = fullScreenQuality // loginFull
param.args[2] = fullScreenQuality // loginMobileFull
}
}
instance.qualityStrategyProviderClass?.hookBeforeMethod(
instance.selectQuality(),
instance.autoSupremumQualityClass,
Boolean::class.javaPrimitiveType, // isFullscreen
Boolean::class.javaPrimitiveType // isVideoPortrait
) { param ->
// videoQuality = when {
// isVideoPortrait && isFullscreen -> loginFull
// isVideoPortrait && !isFullscreen -> unloginFull
// isFullscreen -> loginHalf
// else -> unloginHalf
// }
param.args[2] = true
}
}
}
}
@@ -0,0 +1,40 @@
package me.iacn.biliroaming.hook
import android.os.Bundle
import android.view.View
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.hookAfterMethod
import me.iacn.biliroaming.utils.sPrefs
class VipSectionHook(classLoader: ClassLoader) : BaseHook(classLoader) {
override fun startHook() {
if (!sPrefs.getBoolean("hidden", false)
|| !sPrefs.getBoolean("remove_vip_section", false)
) return
instance.homeUserCenterClass!!.hookAfterMethod(
"onViewCreated",
View::class.java,
Bundle::class.java
) {
val obj = it.thisObject
val vipModuleManager = instance.homeUserCenterClass!!.declaredFields.single {
// $mineVipModuleManager
it.type.toString().contains("MineVipModuleManager")
}.run {
isAccessible = true
get(obj)
}
vipModuleManager::class.java.declaredMethods.single {
// $method(isTeenager: Boolean)
it.parameterCount == 1 &&
it.parameterTypes[0] == Boolean::class.java
}.run {
isAccessible = true
invoke(vipModuleManager, true)
}
}
}
}
@@ -0,0 +1,89 @@
package me.iacn.biliroaming.hook
import android.graphics.Bitmap
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import me.iacn.biliroaming.BuildConfig
import me.iacn.biliroaming.XposedInit.Companion.moduleRes
import me.iacn.biliroaming.utils.*
class WebViewHook(classLoader: ClassLoader) : BaseHook(classLoader) {
private val hookedClient = HashSet<Class<*>>()
private val jsHooker = object : Any() {
@Suppress("UNUSED")
@JavascriptInterface
fun hook(url: String, text: String): String {
return this@WebViewHook.hook(url, text)
}
@Suppress("UNUSED")
@JavascriptInterface
fun saveImage(url: String) {
MainScope().launch(Dispatchers.IO) {
CommentImageHook.saveImage(url)
}
}
}
private val js by lazy {
runCatchingOrNull {
moduleRes.assets.open("xhook.js")
.use { it.bufferedReader().readText() }
} ?: ""
}
override fun startHook() {
Log.d("startHook: WebView")
WebView::class.java.hookBeforeMethod(
"setWebViewClient", WebViewClient::class.java
) { param ->
val clazz = param.args[0].javaClass
(param.thisObject as WebView).addJavascriptInterface(jsHooker, "hooker")
if (hookedClient.contains(clazz)) return@hookBeforeMethod
try {
clazz.getDeclaredMethod(
"onPageStarted",
WebView::class.java, String::class.java, Bitmap::class.java
).hookBeforeMethod { p ->
val webView = p.args[0] as WebView
webView.evaluateJavascript("""(function(){$js})()""".trimMargin(), null)
}
if (sPrefs.getBoolean("save_comment_image", false)) {
clazz.getDeclaredMethod(
"onPageFinished",
WebView::class.java, String::class.java
).hookBeforeMethod { p ->
val webView = p.args[0] as WebView
val url = p.args[1] as String
if (url.startsWith("https://www.bilibili.com/h5/note-app/view")) {
webView.evaluateJavascript(
"""(function(){for(var i=0;i<document.images.length;++i){if(document.images[i].className==='img-preview'){document.images[i].addEventListener("contextmenu",(e)=>{hooker.saveImage(e.target.currentSrc);})}}})()""",
null
)
}
}
}
hookedClient.add(clazz)
Log.d("hook webview $clazz")
} catch (_: NoSuchMethodException) {
}
}
}
@Suppress("UNUSED_PARAMETER")
fun hook(url: String, text: String): String {
return text
}
override fun lateInitHook() {
if (BuildConfig.DEBUG) {
WebView.setWebContentsDebuggingEnabled(true)
}
}
}
@@ -0,0 +1,12 @@
package me.iacn.biliroaming.hook.gson
interface GsonProcessor {
/** Full class name of the target deserialized type */
val targetClassName: String
/** Return true if this processor's settings are enabled */
fun shouldEnable(): Boolean
/** Process the deserialized result object */
fun process(result: Any)
}
@@ -0,0 +1,17 @@
package me.iacn.biliroaming.hook.gson
import me.iacn.biliroaming.utils.sPrefs
import me.iacn.biliroaming.utils.getObjectFieldOrNullAs
class SplashListProcessor : GsonProcessor {
override val targetClassName =
"tv.danmaku.bili.splash.ad.model.SplashListResponse"
override fun shouldEnable() =
sPrefs.getBoolean("hidden", false) && sPrefs.getBoolean("purify_splash", false)
override fun process(result: Any) {
result.getObjectFieldOrNullAs<MutableList<*>>("splashList")?.clear()
result.getObjectFieldOrNullAs<MutableList<*>>("strategyList")?.clear()
}
}
@@ -0,0 +1,16 @@
package me.iacn.biliroaming.hook.gson
import me.iacn.biliroaming.utils.sPrefs
import me.iacn.biliroaming.utils.getObjectFieldOrNullAs
class SplashShowProcessor : GsonProcessor {
override val targetClassName =
"tv.danmaku.bili.splash.ad.model.SplashShowResponse"
override fun shouldEnable() =
sPrefs.getBoolean("hidden", false) && sPrefs.getBoolean("purify_splash", false)
override fun process(result: Any) {
result.getObjectFieldOrNullAs<MutableList<*>>("strategyList")?.clear()
}
}
@@ -0,0 +1,12 @@
package me.iacn.biliroaming.hook.kotlinx
interface KotlinxProcessor {
/** Serial descriptor name matching `serializer.descriptor.serialName` */
val targetSerialName: String
/** Return true if this processor's settings are enabled */
fun shouldEnable(): Boolean
/** Process the deserialized result object */
fun process(result: Any)
}
@@ -0,0 +1,22 @@
package me.iacn.biliroaming.hook.kotlinx
import me.iacn.biliroaming.utils.sPrefs
class KotlinxSplashListProcessor : KotlinxProcessor {
override val targetSerialName =
"kntr.srcs.app.splash.model.SplashListResponse"
override fun shouldEnable() =
sPrefs.getBoolean("hidden", false) && sPrefs.getBoolean("purify_splash", false)
override fun process(result: Any) {
try {
result.javaClass.declaredFields
.filter { MutableList::class.java.isAssignableFrom(it.type) }
.forEach { field ->
field.isAccessible = true
(field.get(result) as? MutableList<*>)?.clear()
}
} catch (_: Throwable) {}
}
}
@@ -0,0 +1,24 @@
package me.iacn.biliroaming.hook.kotlinx
import me.iacn.biliroaming.utils.sPrefs
class KotlinxSplashShowProcessor : KotlinxProcessor {
override val targetSerialName =
"kntr.srcs.app.splash.model.SplashShowStrategy"
override fun shouldEnable() =
sPrefs.getBoolean("hidden", false) && sPrefs.getBoolean("purify_splash", false)
override fun process(result: Any) {
try {
result.javaClass.declaredFields
.filter { !it.type.isPrimitive }
.filter { it.type != String::class.java
&& it.type.name != "kotlinx.serialization.json.JsonObject" }
.forEach { field ->
field.isAccessible = true
field.set(result, null)
}
} catch (_: Throwable) {}
}
}
@@ -0,0 +1,132 @@
package me.iacn.biliroaming.hook
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.utils.BilibiliSponsorBlock
import me.iacn.biliroaming.utils.Log
import me.iacn.biliroaming.utils.av2bv
import me.iacn.biliroaming.utils.callMethod
import me.iacn.biliroaming.utils.callMethodAs
import me.iacn.biliroaming.utils.hookAfterMethod
import me.iacn.biliroaming.utils.hookBeforeMethod
import me.iacn.biliroaming.utils.mossResponseHandlerReplaceProxy
import me.iacn.biliroaming.utils.sPrefs
import java.lang.ref.WeakReference
class SkipVideoAd(classLoader: ClassLoader) : BaseHook(classLoader) {
private var lastSeekTime = 0L
private var playerRef: WeakReference<Any>? = null
private val player get() = playerRef?.get()
private var duration: Int = -1
private var segments : List<BilibiliSponsorBlock.Segment>? = null
private var bvid: String = ""
private var cid: String = ""
private var waitTime = 1000
override fun startHook() {
if (!sPrefs.getBoolean("skip_video_ad", false)) return
Log.d("startHook: SkipVideoAd")
instance.playerMossClass?.apply {
hookBeforeMethod("executePlayViewUnite",
instance.playViewUniteReqClass
) { param ->
val req = param.args[0]
bvid = req.callMethodAs("getBvid")
val vod = req.callMethod("getVod")?:return@hookBeforeMethod
if (bvid.isEmpty()){
val aid = vod.callMethodAs<Long>("getAid")
if (aid==-1L){
return@hookBeforeMethod
}
bvid = av2bv(aid)
}
cid = vod.callMethodAs<Long>("getCid").toString()
}
hookBeforeMethod("playViewUnite",
instance.playViewUniteReqClass,
instance.mossResponseHandlerClass
){ param ->
param.args[1] = param.args[1].mossResponseHandlerReplaceProxy { reply ->
reply ?: return@mossResponseHandlerReplaceProxy null
val playArc = reply.callMethod("getPlayArc")?:return@mossResponseHandlerReplaceProxy null
cid = playArc.callMethodAs<Long>("getCid").toString()
val aid = playArc.callMethodAs<Long>("getAid")?:-1L
if (aid==-1L){
return@mossResponseHandlerReplaceProxy null
}
bvid = av2bv(aid)
null
}
}
}
instance.playerCoreServiceV2Class?.apply {
hookAfterMethod("G1", Int::class.java) { param ->
playerRef = WeakReference(param.thisObject)
val state = param.args[0] as Int
if (state in 3..5 && duration<=0) {
duration = (player?.callMethodAs<Int>("getDuration") ?: -1)
}
if(state == 2) {
duration = -1
segments = null
CoroutineScope(Dispatchers.IO).launch{
var retryCount = 0
val maxRetries = 3
while (retryCount < maxRetries) {
segments = BilibiliSponsorBlock(bvid, cid).getSegments()
if (segments.isNullOrEmpty()) {
retryCount++
delay(1000)
} else {
break
}
}
if (segments == null){
Log.toast("广告片段数据获取失败")
return@launch
}
}
}
}
hookAfterMethod("getCurrentPosition") { param ->
val now = System.currentTimeMillis()
if (now - lastSeekTime > waitTime) {
lastSeekTime = now
waitTime = if(seekTo(param.result as Int)) 3000 else 1000
}
}
}
}
private fun seekTo(position: Int?): Boolean {
if (position != null) {
if (position > duration) return false
}
if (segments != null) {
for (segment in segments) {
val start = (segment.segment[0]*1000).toInt()
val end = (segment.segment[1]*1000).toInt()
if (position in start..<end) {
Log.toast("已跳过广告片段")
player?.callMethod("seekTo", end)
return true
}
}
}
return false
}
}
@@ -0,0 +1,685 @@
package me.iacn.biliroaming.network
import android.annotation.SuppressLint
import android.app.AndroidAppHelper
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.BuildConfig
import me.iacn.biliroaming.XposedInit
import me.iacn.biliroaming.hook.BangumiSeasonHook.Companion.lastSeasonInfo
import me.iacn.biliroaming.utils.*
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import java.util.zip.GZIPInputStream
import java.util.zip.InflaterInputStream
/**
* Created by iAcn on 2019/3/27
* Email i@iacn.me
*/
object BiliRoamingApi {
private const val BILI_SEASON_URL = "api.bilibili.com/pgc/view/v2/app/season"
private const val BILI_SEARCH_URL = "/x/v2/search/type"
private const val BILIPLUS_VIEW_URL = "www.biliplus.com/api/view"
private const val BILI_MEDIA_URL = "www.bilibili.com/bangumi/media/md"
private const val BILI_SECTION_URL = "api.bilibili.com/pgc/web/season/section"
private const val BILI_CARD_URL = "https://account.bilibili.com/api/member/getCardByMid"
private const val BILI_PAGELIST = "api.bilibili.com/x/player/pagelist"
private const val BILI_MODULE_TEMPLATE =
"{\"data\": {},\"id\": 0,\"module_style\": {\"hidden\": 0,\"line\": 1},\"more\": \"查看更多\",\"style\": \"positive\",\"title\": \"选集\"}"
private const val BILI_RIGHT_TEMPLATE =
"{\"allow_demand\":0,\"allow_dm\":1,\"allow_download\":0,\"area_limit\":0}"
private const val BILI_VIP_BADGE_TEMPLATE =
"{\"bg_color\":\"#FB7299\",\"bg_color_night\":\"#BB5B76\",\"text\":\"%s\"}"
private const val PATH_PLAYURL = "/pgc/player/api/playurl"
private const val THAILAND_PATH_PLAYURL = "/intl/gateway/v2/ogv/playurl"
private const val THAILAND_PATH_SUBTITLES = "/intl/gateway/v2/app/subtitle"
private const val THAILAND_PATH_SEARCH = "/intl/gateway/v2/app/search/type"
private const val THAILAND_PATH_SEASON = "/intl/gateway/v2/ogv/view/app/season"
const val overseaTestParams =
"cid=120453316&ep_id=285145&otype=json&fnval=16&module=pgc&platform=android&test=true"
const val mainlandTestParams =
"cid=13073143&ep_id=100615&otype=json&fnval=16&module=pgc&platform=android&test=true"
private val seasonCache: AtomicReference<Triple<Int, AtomicReference<String?>, CountDownLatch>?> =
AtomicReference(null)
@JvmStatic
fun getSeason(info: Map<String, String?>, season: JSONObject?): String? {
val seasonId = info.getOrDefault("season_id", null)?.toInt() ?: return null
val cache = seasonCache.get()
val cacheTuple = if (seasonId != 0) {
if (cache?.first == seasonId) {
cache.third.await()
return cache.second.get()
} else {
Triple(seasonId, AtomicReference<String?>(null), CountDownLatch(1)).also {
seasonCache.compareAndSet(cache, it)
}
}
} else null
var seasonJson = season
seasonJson = seasonJson ?: run {
val query = mapOf(
"season_id" to seasonId.toString(),
)
val content = getContent(Uri.Builder().scheme("https").encodedAuthority(BILI_SEASON_URL).encodedQuery(signQuery(query)).toString())
content?.toJSONObject()?.optJSONObject("data")
} ?: run {
val content = getContent(Uri.Builder().scheme("https").encodedAuthority("${BILI_MEDIA_URL}${seasonId}").toString()) ?: return@run null
val json = Regex("""[\w\W]*window\.__INITIAL_STATE__=(.*);\(function\(\)[\w\W]*""").matchEntire(content)?.groupValues?.get(1)?.toJSONObject() ?: return@run null
json.optJSONObject("mediaInfo")?.also {
it.put("modules", JSONArray().put(JSONObject().put("data", JSONObject().put("seasons", it.optJSONObject("seasons")))))
}
}
seasonJson = seasonJson?.let {
fixSection(it)
fixEpisodes(it)
fixPrevueSection(it)
reconstructModules(it)
fixRight(it)
JSONObject().put("code", 0).put("result", it)
} ?: run {
val thUrl = sPrefs.getString("th_server", null)
val mobiApp = sPrefs.getString("th_server_platform", platform)!!
if (thUrl != null) {
val builder = Uri.Builder()
builder.scheme("https").encodedAuthority(thUrl)
.encodedPath(THAILAND_PATH_SEASON)
.appendQueryParameter("s_locale", "zh_SG")
.appendQueryParameter("access_key", instance.getCustomizeAccessKey("th_server"))
.appendQueryParameter("mobi_app", "bstar_a")
.appendQueryParameter("build", "1080003")
.appendQueryParameter("season_id", seasonId.toString())
.appendQueryParameter("ep_id", info.getOrDefault("ep_id", "0"))
getContent(builder.toString(), mobiApp)?.toJSONObject()?.also {
it.optJSONObject("result")?.let { result ->
fixThailandSeason(result)
return@run it
}
checkErrorToast(it, true)
}
} else {
checkErrorToast("""{"code" = -404, "message" = "未设置泰区解析服务器"}""".toJSONObject())
}
null
}
return seasonJson?.toString().also {
if (seasonJson?.optInt("code", -1) == 0)
cacheTuple?.second?.set(it)
cacheTuple?.third?.countDown()
}
}
@JvmStatic
private fun fixSection(result: JSONObject) {
val seasonId = result.optString("season_id")
val uri = Uri.Builder()
.scheme("https")
.encodedAuthority(BILI_SECTION_URL)
.appendQueryParameter("season_id", seasonId)
.toString()
val sectionJson = getContent(uri).toJSONObject().optJSONObject("result") ?: return
val sections = sectionJson.optJSONArray("section") ?: return
for ((i, section) in sections.iterator().withIndex()) {
section.put("episode_id", i)
val newEpisodes = JSONArray()
for (episode in section.optJSONArray("episodes").orEmpty()) {
newEpisodes.put(episode)
}
section.put("episodes", newEpisodes)
}
result.put("section", sections)
result.optJSONObject("newest_ep")?.run {
put("title", optString("index"))
result.put("new_ep", this)
}
val newEpisodes = JSONArray()
for (episode in sectionJson.optJSONObject("main_section")?.optJSONArray("episodes")
.orEmpty()) {
newEpisodes.put(episode)
}
result.put("episodes", newEpisodes)
}
@JvmStatic
fun getPagelist(aid: String) = getContent(
Uri.Builder().scheme("https")
.encodedAuthority(BILI_PAGELIST)
.appendQueryParameter("aid", aid).toString()
)?.toJSONObject()?.let {
if (it.optInt("code", -1) == 0) it else null
}
@JvmStatic
fun getThailandSubtitles(epId: String?): String? {
Log.d("Getting subtitle $epId form thailand")
epId ?: return null
val thUrl = sPrefs.getString("th_server", null) ?: return null
val uri = Uri.Builder()
.scheme("https")
.encodedAuthority(thUrl)
.encodedPath(THAILAND_PATH_SUBTITLES)
.appendQueryParameter("ep_id", epId)
.toString()
return getContent(uri)
}
@JvmStatic
fun getAreaSearchBangumi(query: Map<String, String>, area: String, type: String): String? {
if (area == "th") {
return getThailandSearchBangumi(query, type)
}
val hostUrl = sPrefs.getString(area + "_server", null) ?: return null
val uri = Uri.Builder()
.scheme("https")
.encodedAuthority(hostUrl)
.encodedPath(BILI_SEARCH_URL)
.encodedQuery(
signQuery(
query, mapOf(
"type" to type,
"build" to "6400000",
"area" to area,
)
)
)
.toString()
return getContent(uri)
}
@JvmStatic
fun getThailandSearchBangumi(query: Map<String, String>, type: String): String? {
val thUrl = sPrefs.getString("th_server", null) ?: return null
val uri = Uri.Builder()
.scheme("https")
.encodedAuthority(thUrl)
.encodedPath(THAILAND_PATH_SEARCH)
.encodedQuery(
signQuery(
query, mapOf(
"type" to type,
"appkey" to "7d089525d3611b1c",
"build" to "1001310",
"mobi_app" to "bstar_a",
"platform" to "android",
"s_locale" to "zh_SG",
"c_locale" to "zh_SG",
"sim_code" to "52004",
"lang" to "hans",
)
)
)
.toString()
return getContent(uri)?.replace(
"bstar://bangumi/season/",
"https://bangumi.bilibili.com/anime/"
)
}
@JvmStatic
private fun fixPrevueSection(result: JSONObject) {
result.put("prevueSection", result.optJSONObject("section"))
}
@JvmStatic
private fun fixEpisodes(result: JSONObject, sid: Int = 0) {
val episodes = result.optJSONArray("episodes")
for ((eid, episode) in episodes.orEmpty().iterator().withIndex()) {
fixRight(episode)
if (episode.optInt("badge_type", -1) == 0)
episode.remove("badge_info")
if (episode.optString("badge") != "受限")
episode.put(
"badge_info",
JSONObject(BILI_VIP_BADGE_TEMPLATE.format(episode.optString("badge")))
)
episode.put("ep_index", eid + 1)
episode.put("section_index", sid + 1)
}
for ((off, section) in result.optJSONArray("section").orEmpty().iterator().withIndex()) {
fixEpisodes(section, sid + off + 1)
}
}
@JvmStatic
private fun reconstructModules(result: JSONObject) {
var id = 0
val module = BILI_MODULE_TEMPLATE.toJSONObject()
val episodes = result.optJSONArray("episodes")
module.optJSONObject("data")?.put("episodes", episodes)
module.put("id", ++id)
val modules = arrayListOf(module)
if (result.has("section")) {
val sections = result.optJSONArray("section")
for (section in sections.orEmpty()) {
val sectionModule = BILI_MODULE_TEMPLATE.toJSONObject()
.put("data", section)
.put("style", "section")
.put("title", section.optString("title"))
.put("id", ++id)
modules.add(sectionModule)
}
}
if (result.has("seasons")) {
val seasons = result.optJSONArray("seasons")
for (season in seasons.orEmpty()) {
season.put("title", season.optString("season_title"))
}
val seasonModule = BILI_MODULE_TEMPLATE.toJSONObject()
seasonModule.put("data", JSONObject().put("seasons", seasons))
.put("style", "season")
.put("title", "")
.put("id", ++id)
.put("module_style", JSONObject("{\"line\": 1}"))
modules.add(seasonModule)
}
// work around
result.put("modules", JSONArray(modules))
}
@JvmStatic
private fun fixRight(result: JSONObject) {
result.optJSONObject("rights")?.run {
put("area_limit", 0)
put("allow_dm", 1)
} ?: run { result.put("rights", BILI_RIGHT_TEMPLATE.toJSONObject()) }
}
class CustomServerException(private val errors: Map<String, String>) : Throwable() {
override val message: String
get() = errors.asSequence().joinToString("\n") { "${it.key}: ${it.value}" }.trim()
}
@JvmStatic
fun getPlayUrl(queryString: String?, priorityArea: Array<String>? = null): String? {
queryString ?: return null
val twUrl = sPrefs.getString("tw_server", null)
val hkUrl = sPrefs.getString("hk_server", null)
val cnUrl = sPrefs.getString("cn_server", null)
val thUrl = sPrefs.getString("th_server", null)
val hostList = LinkedHashMap<String, String>(4, 1f, true)
if (hostList.isEmpty())
// reversely
linkedMapOf("tw" to twUrl, "hk" to hkUrl, "th" to thUrl, "cn" to cnUrl).filterKeys {
if (!sPrefs.getString("${it}_server_accessKey", null).isNullOrEmpty())
return@filterKeys true
it != (runCatchingOrNull { XposedInit.country.get(5L, TimeUnit.SECONDS) } ?: true)
}.filterValues {
it != null
}.mapValuesTo(hostList) {
it.value!!
}
val epIdStartIdx = queryString.indexOf("ep_id=")
val epIdEndIdx = queryString.indexOf("&", epIdStartIdx)
val epId = queryString.substring(epIdStartIdx + 6, epIdEndIdx)
if (!lastSeasonInfo.containsKey("ep_ids") || lastSeasonInfo["ep_ids"]?.contains(epId) != true)
lastSeasonInfo.clear()
lastSeasonInfo["title"]?.run {
if (contains(Regex("僅.*台")) && twUrl != null) hostList["tw"]
if (contains(Regex("僅.*港")) && hkUrl != null) hostList["hk"]
if (contains(Regex("[仅|僅].*[东南亚|其他]")) && thUrl != null) hostList["th"]
}
priorityArea?.forEach { area ->
if (hostList.containsKey(area)) hostList[area]
}
if (hostList.isEmpty()) return null
val seasonId = lastSeasonInfo["season_id"] ?: if (epId.isEmpty()) null else "ep$epId"
if (seasonId != null && sCaches.contains(seasonId)) {
val cachedArea = sCaches.getString(seasonId, null)
if (hostList.containsKey(cachedArea)) {
Log.d("use cached area $cachedArea for $seasonId")
hostList[cachedArea]
}
}
val errors: MutableMap<String, String> = mutableMapOf()
for ((area, host) in hostList.toList().asReversed()) {
val accessKey = instance.getCustomizeAccessKey("${area}_server") ?: ""
val mobiApp = sPrefs.getString("${area}_server_platform", platform)!!
val extraMap = if (area == "th") mapOf(
"area" to area,
"appkey" to "7d089525d3611b1c",
"build" to "1001310",
"mobi_app" to "bstar_a",
"platform" to "android",
"access_key" to accessKey,
)
else mapOf(
"area" to area,
"access_key" to accessKey,
)
val path = if (area == "th") THAILAND_PATH_PLAYURL else PATH_PLAYURL
val uri = Uri.Builder()
.scheme("https")
.encodedAuthority(host)
.encodedPath(path)
.encodedQuery(signQuery(queryString, extraMap))
.toString()
getContent(uri, mobiApp)?.let {
Log.d("use server $area $host for playurl")
if (it.contains("\"code\":0")) {
lastSeasonInfo["area"] = area
lastSeasonInfo["epid"] = epId
if (seasonId != null && !sCaches.contains(seasonId) || sCaches.getString(
seasonId,
null
) != area
) {
sCaches.edit().run {
putString(seasonId, area)
lastSeasonInfo["ep_ids"]?.split(";")
?.forEach { epId -> putString("ep$epId", area) }
apply()
}
}
return if (area == "th") fixThailandPlayurl(it) else it
}
errors.put(area, JSONObject(it).optString("message"))
} ?: errors.putIfAbsent(area, "服务器不可用")
}
throw CustomServerException(errors)
}
@JvmStatic
fun fixThailandPlayurl(result: String): String {
val input = JSONObject(result)
val videoInfo = result.toJSONObject().optJSONObject("data")?.optJSONObject("video_info")
val streamList = videoInfo?.optJSONArray("stream_list")
val dashAudio = videoInfo?.optJSONArray("dash_audio")
val output = JSONObject().apply {
put("format", "flv720")
put("type", "DASH")
put("result", "suee")
put("video_codecid", 7)
put("no_rexcode", 0)
put("code", input.optInt("code"))
put("message", input.optInt("message"))
put("timelength", videoInfo?.optInt("timelength"))
put("quality", videoInfo?.optInt("quality"))
put("accept_format", "hdflv2_4k,hdflv2_hdr,hdflv2_dolby,hdflv2,flv,flv720,flv480,mp4")
}
val acceptQuality = JSONArray()
val acceptDescription = JSONArray()
val dash = JSONObject().apply {
put("duration", 0)
put("minBufferTime", 0.0)
put("min_buffer_time", 0.0)
}
val fixedAudio = JSONArray().apply {
for (audio in dashAudio.orEmpty()) {
put(audio)
}
}
dash.put("audio", fixedAudio)
val supportFormats = JSONArray()
val dashVideo = JSONArray()
for (stream in streamList.orEmpty()) {
if (stream.optJSONObject("dash_video")?.optString("base_url").isNullOrBlank()) {
continue
}
stream.optJSONObject("stream_info")?.let {
supportFormats.put(it)
}
stream.optJSONObject("stream_info")?.let {
acceptQuality.put(it.optInt("quality"))
}
stream.optJSONObject("stream_info")?.let {
acceptDescription.put(it.optString("new_description"))
}
stream.optJSONObject("dash_video")?.let {
it.put("id", stream.optJSONObject("stream_info")?.optInt("quality"))
dashVideo.put(it)
}
}
dash.put("video", dashVideo)
output.put("accept_quality", acceptQuality)
output.put("accept_description", acceptDescription)
output.put("support_formats", supportFormats)
output.put("dash", dash)
return output.toString()
}
@JvmStatic
fun fixThailandSeason(result: JSONObject) {
val episodes = JSONArray()
// 强制已追番
result.optJSONObject("user_status")?.put("follow", 1)
for ((mid, module) in result.optJSONArray("modules").orEmpty().iterator().withIndex()) {
val data = module.optJSONObject("data") ?: continue
val sid = module.optInt("id", mid + 1)
for ((eid, ep) in data.optJSONArray("episodes").orEmpty()
.iterator().withIndex()) {
if (ep.optInt("status") == 13) {
ep.put("badge", "泰区会员")
ep.put("badge_info", JSONObject().apply {
put("bg_color", "#FB7299")
put("bg_color_night", "#BB5B76")
put("text", "泰区会员")
})
}
ep.put("status", 2)
ep.put("episode_status", 2)
ep.put("ep_id", ep.optInt("id"))
ep.put("index", ep.optString("title"))
ep.put("link", "https://www.bilibili.com/bangumi/play/ep${ep.optInt("id")}")
ep.put("indexTitle", ep.optString("long_title"))
ep.put("ep_index", eid + 1)
ep.put("section_index", sid + 1)
fixRight(ep)
if (ep.optInt("cid", 0) == 0) {
ep.put("cid", ep.optInt("id"))
if (!sPrefs.getBoolean("force_th_comment", false))
ep.optJSONObject("rights")?.put("allow_dm", 0)
}
if (ep.optInt("aid", 0) == 0) {
ep.put("aid", result.optInt("season_id"))
if (!sPrefs.getBoolean("force_th_comment", false))
ep.optJSONObject("rights")?.put("area_limit", 1)
}
ep.put("duration", 114514)
episodes.put(ep)
}
data.put("id", sid)
}
result.put("episodes", episodes)
val style = JSONArray()
for (i in result.optJSONArray("styles").orEmpty()) {
style.put(i.optString("name"))
}
result.put("style", style)
result.optJSONObject("rights")?.put("watch_platform", 1)
?.put("allow_comment", 0)
result.apply {
put("actors", result.optJSONObject("actor")?.optString("info"))
put("is_paster_ads", 0)
put("jp_title", result.optString("origin_name"))
put("newest_ep", result.optJSONObject("new_ep"))
put("season_status", result.optInt("status"))
put("season_title", result.optString("title"))
put("total_ep", episodes.length())
}
}
@JvmStatic
fun getView(queryString: String?): String? {
val builder = Uri.Builder()
builder.scheme("https").encodedAuthority(BILIPLUS_VIEW_URL)
builder.encodedQuery(queryString)
builder.appendQueryParameter("module", "bangumi")
builder.appendQueryParameter("otype", "json")
builder.appendQueryParameter("platform", "android")
return getContent(builder.toString())
}
@JvmStatic
fun getSpace(mid: Long): String? {
val content = getContent("$BILI_CARD_URL?mid=$mid").toJSONObject()
if (content.optInt("code") != 0) return null
val card = content.optJSONObject("card") ?: return null
val levelInfo = card.optJSONObject("level_info") ?: return null
val officialVerify = card.optJSONObject("official_verify") ?: return null
return """{"relation":-999,"guest_relation":-999,"default_tab":"video","is_params":true,"setting":{"fav_video":0,"coins_video":0,"likes_video":0,"bangumi":0,"played_game":0,"groups":0,"comic":0,"bbq":0,"dress_up":0,"disable_following":0,"live_playback":1,"close_space_medal":0,"only_show_wearing":0},"tab":{"archive":true,"article":true,"clip":true,"album":true,"favorite":false,"bangumi":false,"coin":false,"like":false,"community":false,"dynamic":true,"audios":true,"shop":false,"mall":false,"ugc_season":false,"comic":false,"cheese":false,"sub_comic":false,"activity":false,"series":false},"card":{"mid":"$mid","name":"${
card.optString(
"name"
)
}","approve":false,"sex":"${card.optString("sex")}","rank":"${card.optString("rank")}","face":"${
card.optString(
"face"
)
}","DisplayRank":"","regtime":0,"spacesta":0,"birthday":"","place":"","description":"该页面由哔哩漫游修复","article":0,"attentions":null,"fans":${
card.optInt(
"fans",
114
)
},"friend":${card.optInt("friend", 514)},"attention":${
card.optInt(
"attention",
233
)
},"sign":"【该页面由哔哩漫游修复】${card.optString("sign")}","level_info":{"current_level":${
levelInfo.optInt(
"current_level"
)
},"current_min":${levelInfo.optInt("current_min")},"current_exp":${levelInfo.optInt("current_exp")},"next_exp":"${
levelInfo.optInt(
"next_exp"
)
}"},"pendant":{"pid":0,"name":"","image":"","expire":0,"image_enhance":"","image_enhance_frame":""},"nameplate":{"nid":0,"name":"","image":"","image_small":"","level":"","condition":""},"official_verify":{"type":${
officialVerify.optInt(
"type"
)
},"desc":"${officialVerify.optString("desc")}","role":3,"title":"${
officialVerify.optString(
"desc"
)
}"},"vip":{"vipType":0,"vipDueDate":0,"dueRemark":"","accessStatus":0,"vipStatus":0,"vipStatusWarn":"","themeType":0,"label":{"path":"","text":"","label_theme":"","text_color":"","bg_style":0,"bg_color":"","border_color":""}},"silence":0,"end_time":0,"silence_url":"","likes":{"like_num":0,"skr_tip":"该页面由哔哩漫游修复"},"pr_info":{},"relation":{"status":1},"is_deleted":0,"honours":{"colour":{"dark":"#CE8620","normal":"#F0900B"},"tags":null},"profession":{}},"images":{"imgUrl":"https://i0.hdslb.com/bfs/album/16b6731618d911060e26f8fc95684c26bddc897c.jpg","night_imgurl":"https://i0.hdslb.com/bfs/album/ca79ebb2ebeee86c5634234c688b410661ea9623.png","has_garb":true,"goods_available":true},"live":{"roomStatus":0,"roundStatus":0,"liveStatus":0,"url":"","title":"","cover":"","online":0,"roomid":0,"broadcast_type":0,"online_hidden":0,"link":""},"archive":{"order":[{"title":"最新发布","value":"pubdate"},{"title":"最多播放","value":"click"}],"count":9999,"item":[]},"series":{"item":[]},"play_game":{"count":0,"item":[]},"article":{"count":0,"item":[],"lists_count":0,"lists":[]},"season":{"count":0,"item":[]},"coin_archive":{"count":0,"item":[]},"like_archive":{"count":0,"item":[]},"audios":{"count":0,"item":[]},"favourite2":{"count":0,"item":[]},"comic":{"count":0,"item":[]},"ugc_season":{"count":0,"item":[]},"cheese":{"count":0,"item":[]},"fans_effect":{},"tab2":[{"title":"动态","param":"dynamic"},{"title":"投稿","param":"contribute","items":[{"title":"视频","param":"video"}]}]}"""
}
@SuppressLint("SetJavaScriptEnabled")
fun getContent(urlString: String, mobiApp: String = platform): String? {
val timeout = 10000
return try {
// Work around for android 7
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.N &&
urlString.startsWith("https") &&
!urlString.contains("bilibili.com")
) {
Log.d("Found Android 7, try to bypass ssl issue")
val handler = Handler(AndroidAppHelper.currentApplication().mainLooper)
val listener = object : Any() {
val latch = CountDownLatch(1)
var result = ""
@Suppress("UNUSED")
@JavascriptInterface
fun callback(r: String) {
result = r
latch.countDown()
}
}
handler.post {
val webView = WebView(currentContext, null)
webView.addJavascriptInterface(listener, "listener")
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
view?.settings?.javaScriptEnabled = true
view?.loadUrl("javascript:listener.callback(document.documentElement.innerText)")
}
}
webView.loadUrl(
urlString, mapOf(
"x-from-biliroaming" to BuildConfig.VERSION_NAME,
"platform-from-biliroaming" to mobiApp,
"Build" to BuildConfig.VERSION_CODE.toString()
)
)
}
try {
if (!listener.latch.await((timeout * 2).toLong(), TimeUnit.MILLISECONDS)) {
Log.toast("连接超时,请重试")
throw IOException("Timeout connection to server")
}
} catch (e: InterruptedException) {
throw IOException("Connection to server was interrupted")
}
return listener.result
} else {
val url = URL(urlString)
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.setRequestProperty("Build", BuildConfig.VERSION_CODE.toString())
connection.connectTimeout = timeout
connection.readTimeout = timeout
connection.setRequestProperty("x-from-biliroaming", BuildConfig.VERSION_NAME)
connection.setRequestProperty(
"Accept-Encoding",
"${if (instance.brotliInputStreamClass != null) "br," else ""}gzip,deflate"
)
connection.setRequestProperty("platform-from-biliroaming", mobiApp)
connection.connect()
if (connection.responseCode == HttpURLConnection.HTTP_OK) {
val inputStream = connection.inputStream
getStreamContent(
when (connection.contentEncoding?.lowercase()) {
"gzip" -> GZIPInputStream(inputStream)
"br" -> instance.brotliInputStreamClass!!.new(inputStream) as InputStream
"deflate" -> InflaterInputStream(inputStream)
else -> inputStream
}
)
} else null
}
} catch (e: Throwable) {
Log.e("getContent error: $e with url $urlString")
Log.e(e)
null
}?.also {
Log.d("getContent url: $urlString mobiApp: $mobiApp")
Log.d("getContent result: $it")
}
}
}
@@ -0,0 +1,110 @@
package me.iacn.biliroaming.utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONException
import java.util.concurrent.TimeUnit
class BilibiliSponsorBlock(
private val bvid: String,
private val cid: String,
) {
companion object {
private const val BASE_URL = "https://bsbsb.top/api/skipSegments/"
internal val httpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
}
private val sha256Digest by lazy {
java.security.MessageDigest.getInstance("SHA-256")
}
}
suspend fun getSegments(): List<Segment>? = withContext(Dispatchers.IO) {
try {
val prefix = bvid.trim().sha256().take(4)
val url = "$BASE_URL$prefix?category=sponsor"
val request = Request.Builder()
.url(url)
.header("origin", "BiliRoaming")
.header("x-ext-version", "1.7.0")
.build()
return@withContext httpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
Log.e("HTTP request failed with code: ${resp.code}")
return@use null
}
val body = resp.body.string()
if (body.isEmpty()) {
Log.e("HTTP request failed with empty body")
return@use null
}
parseSegments(body)
}
} catch (e: Exception) {
Log.e(e)
null
}
}
private fun parseSegments(json: String): List<Segment>? {
try {
val jsonArray = JSONArray(json)
val segments = mutableListOf<Segment>()
if (jsonArray.length() == 0) return null
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
if (obj.optString("videoID") != bvid) continue
val segmentsArray = obj.getJSONArray("segments")
for (j in 0 until segmentsArray.length()) {
val item = segmentsArray.getJSONObject(j)
val segmentArray = item.getJSONArray("segment")
segments.add(
Segment(
segment = floatArrayOf(
segmentArray.getDouble(0).toFloat(),
segmentArray.getDouble(1).toFloat()
),
cid = item.optString("cid"),
UUID = item.optString("UUID"),
category = item.optString("category"),
actionType = item.optString("actionType"),
videoDuration = item.optInt("videoDuration")
)
)
}
break
}
return segments
.filter { it.cid == this.cid }
.sortedBy { it.segment[0] }
} catch (_: JSONException) {
return null
}
}
private fun String.sha256(): String = sha256Digest
.digest(toByteArray())
.joinToString("") { "%02x".format(it) }
data class Segment(
val segment: FloatArray,
val cid: String,
val UUID: String,
val category: String,
val actionType: String,
val videoDuration: Int
)
}
@@ -0,0 +1,17 @@
package me.iacn.biliroaming.utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.URL
suspend fun fetchJson(url: URL) = withContext(Dispatchers.IO) {
try {
JSONObject(url.readText())
} catch (e: Throwable) {
null
}
}
@Suppress("BlockingMethodInNonBlockingContext") // Fuck JetBrain
suspend fun fetchJson(url: String) = fetchJson(URL(url))
@@ -0,0 +1,82 @@
package me.iacn.biliroaming.utils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.Closeable;
import java.lang.reflect.Field;
import java.lang.reflect.Member;
public class DexHelper implements AutoCloseable, Closeable {
public static final int NO_CLASS_INDEX = -1;
private final ClassLoader classLoader;
private final long token;
public DexHelper(@NonNull ClassLoader classLoader) {
this.classLoader = classLoader;
token = load(classLoader);
}
@NonNull
public native long[] findMethodUsingString(
@NonNull String str, boolean matchPrefix, long returnType, short parameterCount,
@Nullable String parameterShorty, long declaringClass, @Nullable long[] parameterTypes,
@Nullable long[] containsParameterTypes, @Nullable int[] dexPriority, boolean findFirst);
@NonNull
public native long[] findMethodInvoking(long methodIndex, long returnType,
short parameterCount, @Nullable String parameterShorty,
long declaringClass, @Nullable long[] parameterTypes,
@Nullable long[] containsParameterTypes,
@Nullable int[] dexPriority, boolean findFirst);
@NonNull
public native long[] findMethodInvoked(long methodIndex, long returnType,
short parameterCount, @Nullable String parameterShorty,
long declaringClass, @Nullable long[] parameterTypes,
@Nullable long[] containsParameterTypes,
@Nullable int[] dexPriority, boolean findFirst);
@NonNull
public native long[] findMethodSettingField(
long fieldIndex, long returnType, short parameterCount,
@Nullable String parameterShorty, long declaringClass, @Nullable long[] parameterTypes,
@Nullable long[] containsParameterTypes, @Nullable int[] dexPriority, boolean findFirst);
@NonNull
public native long[] findMethodGettingField(
long fieldIndex, long returnType, short parameterCount,
@Nullable String parameterShorty, long declaringClass, @Nullable long[] parameterTypes,
@Nullable long[] containsParameterTypes, @Nullable int[] dexPriority, boolean findFirst);
@NonNull
public native long[] findField(long type, @Nullable int[] dexPriority, boolean findFirst);
@Nullable
public native Member decodeMethodIndex(long methodIndex);
public native long encodeMethodIndex(@NonNull Member method);
@Nullable
public native Field decodeFieldIndex(long fieldIndex);
public native long encodeFieldIndex(@NonNull Field field);
public native long encodeClassIndex(@NonNull Class<?> clazz);
@Nullable
public native Class<?> decodeClassIndex(long classIndex);
public native void createFullCache();
@Override
public native void close();
@Override
protected void finalize() {
close();
}
private native long load(@NonNull ClassLoader classLoader);
}
@@ -0,0 +1,498 @@
@file:Suppress("unused")
package me.iacn.biliroaming.utils
import android.content.res.XResources
import dalvik.system.BaseDexClassLoader
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XC_MethodHook.MethodHookParam
import de.robv.android.xposed.XC_MethodReplacement
import de.robv.android.xposed.XposedBridge.*
import de.robv.android.xposed.XposedHelpers.*
import de.robv.android.xposed.callbacks.XC_LayoutInflated
import java.lang.reflect.Field
import java.lang.reflect.Member
import java.lang.reflect.Modifier
import java.util.*
typealias MethodHookParam = MethodHookParam
typealias Replacer = (MethodHookParam) -> Any?
typealias Hooker = (MethodHookParam) -> Unit
fun Class<*>.hookMethod(method: String?, vararg args: Any?) = try {
findAndHookMethod(this, method, *args)
} catch (e: NoSuchMethodError) {
Log.e(e)
null
} catch (e: ClassNotFoundError) {
Log.e(e)
null
} catch (e: ClassNotFoundException) {
Log.e(e)
null
}
fun Member.hookMethod(callback: XC_MethodHook) = try {
hookMethod(this, callback)
} catch (e: Throwable) {
Log.e(e)
null
}
inline fun MethodHookParam.callHooker(crossinline hooker: Hooker) = try {
hooker(this)
} catch (e: Throwable) {
Log.e("Error occurred calling hooker on ${this.method}")
Log.e(e)
}
inline fun MethodHookParam.callReplacer(crossinline replacer: Replacer) = try {
replacer(this)
} catch (e: Throwable) {
Log.e("Error occurred calling replacer on ${this.method}")
Log.e(e)
null
}
inline fun Member.replaceMethod(crossinline replacer: Replacer) =
hookMethod(object : XC_MethodReplacement() {
override fun replaceHookedMethod(param: MethodHookParam) = param.callReplacer(replacer)
})
inline fun Member.hookAfterMethod(crossinline hooker: Hooker) =
hookMethod(object : XC_MethodHook() {
override fun afterHookedMethod(param: MethodHookParam) = param.callHooker(hooker)
})
inline fun Member.hookBeforeMethod(crossinline hooker: (MethodHookParam) -> Unit) =
hookMethod(object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) = param.callHooker(hooker)
})
inline fun Class<*>.hookBeforeMethod(
method: String?,
vararg args: Any?,
crossinline hooker: Hooker
) = hookMethod(method, *args, object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) = param.callHooker(hooker)
})
inline fun Class<*>.hookAfterMethod(
method: String?,
vararg args: Any?,
crossinline hooker: Hooker
) = hookMethod(method, *args, object : XC_MethodHook() {
override fun afterHookedMethod(param: MethodHookParam) = param.callHooker(hooker)
})
inline fun Class<*>.replaceMethod(
method: String?,
vararg args: Any?,
crossinline replacer: Replacer
) = hookMethod(method, *args, object : XC_MethodReplacement() {
override fun replaceHookedMethod(param: MethodHookParam) = param.callReplacer(replacer)
})
fun Class<*>.hookAllMethods(methodName: String?, hooker: XC_MethodHook): Set<XC_MethodHook.Unhook> =
try {
hookAllMethods(this, methodName, hooker)
} catch (e: NoSuchMethodError) {
Log.e(e)
emptySet()
} catch (e: ClassNotFoundError) {
Log.e(e)
emptySet()
} catch (e: ClassNotFoundException) {
Log.e(e)
emptySet()
}
inline fun Class<*>.hookBeforeAllMethods(methodName: String?, crossinline hooker: Hooker) =
hookAllMethods(methodName, object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) = param.callHooker(hooker)
})
inline fun Class<*>.hookAfterAllMethods(methodName: String?, crossinline hooker: Hooker) =
hookAllMethods(methodName, object : XC_MethodHook() {
override fun afterHookedMethod(param: MethodHookParam) = param.callHooker(hooker)
})
inline fun Class<*>.replaceAllMethods(methodName: String?, crossinline replacer: Replacer) =
hookAllMethods(methodName, object : XC_MethodReplacement() {
override fun replaceHookedMethod(param: MethodHookParam) = param.callReplacer(replacer)
})
fun Class<*>.hookConstructor(vararg args: Any?) = try {
findAndHookConstructor(this, *args)
} catch (e: NoSuchMethodError) {
Log.e(e)
null
} catch (e: ClassNotFoundError) {
Log.e(e)
null
} catch (e: ClassNotFoundException) {
Log.e(e)
null
}
inline fun Class<*>.hookBeforeConstructor(vararg args: Any?, crossinline hooker: Hooker) =
hookConstructor(*args, object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) = param.callHooker(hooker)
})
inline fun Class<*>.hookAfterConstructor(vararg args: Any?, crossinline hooker: Hooker) =
hookConstructor(*args, object : XC_MethodHook() {
override fun afterHookedMethod(param: MethodHookParam) = param.callHooker(hooker)
})
inline fun Class<*>.replaceConstructor(vararg args: Any?, crossinline hooker: Hooker) =
hookConstructor(*args, object : XC_MethodReplacement() {
override fun replaceHookedMethod(param: MethodHookParam) = param.callHooker(hooker)
})
fun Class<*>.hookAllConstructors(hooker: XC_MethodHook): Set<XC_MethodHook.Unhook> = try {
hookAllConstructors(this, hooker)
} catch (e: NoSuchMethodError) {
Log.e(e)
emptySet()
} catch (e: ClassNotFoundError) {
Log.e(e)
emptySet()
} catch (e: ClassNotFoundException) {
Log.e(e)
emptySet()
}
inline fun Class<*>.hookAfterAllConstructors(crossinline hooker: Hooker) =
hookAllConstructors(object : XC_MethodHook() {
override fun afterHookedMethod(param: MethodHookParam) = param.callHooker(hooker)
})
inline fun Class<*>.hookBeforeAllConstructors(crossinline hooker: Hooker) =
hookAllConstructors(object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) = param.callHooker(hooker)
})
inline fun Class<*>.replaceAllConstructors(crossinline hooker: Hooker) =
hookAllConstructors(object : XC_MethodReplacement() {
override fun replaceHookedMethod(param: MethodHookParam) = param.callHooker(hooker)
})
fun String.hookMethod(classLoader: ClassLoader, method: String?, vararg args: Any?) = try {
findClass(classLoader).hookMethod(method, *args)
} catch (e: ClassNotFoundError) {
Log.e(e)
null
} catch (e: ClassNotFoundException) {
Log.e(e)
null
}
inline fun String.hookBeforeMethod(
classLoader: ClassLoader,
method: String?,
vararg args: Any?,
crossinline hooker: Hooker
) = try {
findClass(classLoader).hookBeforeMethod(method, *args, hooker = hooker)
} catch (e: ClassNotFoundError) {
Log.e(e)
null
} catch (e: ClassNotFoundException) {
Log.e(e)
null
}
inline fun String.hookAfterMethod(
classLoader: ClassLoader,
method: String?,
vararg args: Any?,
crossinline hooker: Hooker
) = try {
findClass(classLoader).hookAfterMethod(method, *args, hooker = hooker)
} catch (e: ClassNotFoundError) {
Log.e(e)
null
} catch (e: ClassNotFoundException) {
Log.e(e)
null
}
inline fun String.replaceMethod(
classLoader: ClassLoader,
method: String?,
vararg args: Any?,
crossinline replacer: Replacer
) = try {
findClass(classLoader).replaceMethod(method, *args, replacer = replacer)
} catch (e: ClassNotFoundError) {
Log.e(e)
null
} catch (e: ClassNotFoundException) {
Log.e(e)
null
}
fun MethodHookParam.invokeOriginalMethod(): Any? = invokeOriginalMethod(method, thisObject, args)
inline fun <T, R> T.runCatchingOrNull(func: T.() -> R?) = try {
func()
} catch (e: Throwable) {
null
}
fun Any.getObjectField(field: String?): Any? = getObjectField(this, field)
fun Any.getObjectFieldOrNull(field: String?): Any? = runCatchingOrNull {
getObjectField(this, field)
}
@Suppress("UNCHECKED_CAST")
fun <T> Any.getObjectFieldAs(field: String?) = getObjectField(this, field) as T
@Suppress("UNCHECKED_CAST")
fun <T> Any.getObjectFieldOrNullAs(field: String?) = runCatchingOrNull {
getObjectField(this, field) as T
}
fun Any.getIntField(field: String?) = getIntField(this, field)
fun Any.getIntFieldOrNull(field: String?) = runCatchingOrNull {
getIntField(this, field)
}
fun Any.getLongField(field: String?) = getLongField(this, field)
fun Any.getLongFieldOrNull(field: String?) = runCatchingOrNull {
getLongField(this, field)
}
fun Any.getBooleanFieldOrNull(field: String?) = runCatchingOrNull {
getBooleanField(this, field)
}
fun Any.callMethod(methodName: String?, vararg args: Any?): Any? =
callMethod(this, methodName, *args)
fun Any.callMethodOrNull(methodName: String?, vararg args: Any?): Any? = runCatchingOrNull {
callMethod(this, methodName, *args)
}
fun Class<*>.callStaticMethod(methodName: String?, vararg args: Any?): Any? =
callStaticMethod(this, methodName, *args)
fun Class<*>.callStaticMethodOrNull(methodName: String?, vararg args: Any?): Any? =
runCatchingOrNull {
callStaticMethod(this, methodName, *args)
}
@Suppress("UNCHECKED_CAST")
fun <T> Class<*>.callStaticMethodAs(methodName: String?, vararg args: Any?) =
callStaticMethod(this, methodName, *args) as T
@Suppress("UNCHECKED_CAST")
fun <T> Class<*>.callStaticMethodOrNullAs(methodName: String?, vararg args: Any?) =
runCatchingOrNull {
callStaticMethod(this, methodName, *args) as T
}
@Suppress("UNCHECKED_CAST")
fun <T> Class<*>.getStaticObjectFieldAs(field: String?) = getStaticObjectField(this, field) as T
@Suppress("UNCHECKED_CAST")
fun <T> Class<*>.getStaticObjectFieldOrNullAs(field: String?) = runCatchingOrNull {
getStaticObjectField(this, field) as T
}
fun Class<*>.getStaticObjectField(field: String?): Any? = getStaticObjectField(this, field)
fun Class<*>.getStaticObjectFieldOrNull(field: String?): Any? = runCatchingOrNull {
getStaticObjectField(this, field)
}
fun Class<*>.setStaticObjectField(field: String?, obj: Any?) = apply {
setStaticObjectField(this, field, obj)
}
fun Class<*>.setStaticObjectFieldIfExist(field: String?, obj: Any?) = apply {
try {
setStaticObjectField(this, field, obj)
} catch (ignored: Throwable) {
}
}
inline fun <reified T> Class<*>.findFieldByExactType(): Field? =
findFirstFieldByExactType(this, T::class.java)
fun Class<*>.findFieldByExactType(type: Class<*>): Field? =
findFirstFieldByExactType(this, type)
@Suppress("UNCHECKED_CAST")
fun <T> Any.callMethodAs(methodName: String?, vararg args: Any?) =
callMethod(this, methodName, *args) as T
@Suppress("UNCHECKED_CAST")
fun <T> Any.callMethodOrNullAs(methodName: String?, vararg args: Any?) = runCatchingOrNull {
callMethod(this, methodName, *args) as T
}
fun Any.callMethod(methodName: String?, parameterTypes: Array<Class<*>>, vararg args: Any?): Any? =
callMethod(this, methodName, parameterTypes, *args)
fun Any.callMethodOrNull(
methodName: String?,
parameterTypes: Array<Class<*>>,
vararg args: Any?
): Any? = runCatchingOrNull {
callMethod(this, methodName, parameterTypes, *args)
}
fun Class<*>.callStaticMethod(
methodName: String?,
parameterTypes: Array<Class<*>>,
vararg args: Any?
): Any? = callStaticMethod(this, methodName, parameterTypes, *args)
fun Class<*>.callStaticMethodOrNull(
methodName: String?,
parameterTypes: Array<Class<*>>,
vararg args: Any?
): Any? = runCatchingOrNull {
callStaticMethod(this, methodName, parameterTypes, *args)
}
fun String.findClass(classLoader: ClassLoader?): Class<*> = findClass(this, classLoader)
infix fun String.on(classLoader: ClassLoader?): Class<*> = findClass(this, classLoader)
fun String.findClassOrNull(classLoader: ClassLoader?): Class<*>? =
findClassIfExists(this, classLoader)
infix fun String.from(classLoader: ClassLoader?): Class<*>? =
findClassIfExists(this, classLoader)
fun Class<*>.new(vararg args: Any?): Any = newInstance(this, *args)
fun Class<*>.new(parameterTypes: Array<Class<*>>, vararg args: Any?): Any =
newInstance(this, parameterTypes, *args)
fun Class<*>.findField(field: String?): Field = findField(this, field)
fun Class<*>.findFieldOrNull(field: String?): Field? = findFieldIfExists(this, field)
fun <T> T.setIntField(field: String?, value: Int) = apply {
setIntField(this, field, value)
}
fun <T> T.setLongField(field: String?, value: Long) = apply {
setLongField(this, field, value)
}
fun <T> T.setObjectField(field: String?, value: Any?) = apply {
setObjectField(this, field, value)
}
fun <T> T.setBooleanField(field: String?, value: Boolean) = apply {
setBooleanField(this, field, value)
}
fun <T> T.setFloatField(field: String?, value: Float) = apply {
setFloatField(this, field, value)
}
inline fun XResources.hookLayout(
id: Int,
crossinline hooker: (XC_LayoutInflated.LayoutInflatedParam) -> Unit
) {
try {
hookLayout(id, object : XC_LayoutInflated() {
override fun handleLayoutInflated(liparam: LayoutInflatedParam) {
try {
hooker(liparam)
} catch (e: Throwable) {
Log.e(e)
}
}
})
} catch (e: Throwable) {
Log.e(e)
}
}
inline fun XResources.hookLayout(
pkg: String,
type: String,
name: String,
crossinline hooker: (XC_LayoutInflated.LayoutInflatedParam) -> Unit
) {
try {
val id = getIdentifier(name, type, pkg)
hookLayout(id, hooker)
} catch (e: Throwable) {
Log.e(e)
}
}
fun Class<*>.findFirstFieldByExactType(type: Class<*>): Field =
findFirstFieldByExactType(this, type)
fun Class<*>.findFirstFieldByExactTypeOrNull(type: Class<*>?): Field? = runCatchingOrNull {
findFirstFieldByExactType(this, type)
}
fun Any.getFirstFieldByExactType(type: Class<*>): Any? =
javaClass.findFirstFieldByExactType(type).get(this)
@Suppress("UNCHECKED_CAST")
fun <T> Any.getFirstFieldByExactTypeAs(type: Class<*>) =
javaClass.findFirstFieldByExactType(type).get(this) as? T
inline fun <reified T : Any> Any.getFirstFieldByExactType() =
javaClass.findFirstFieldByExactType(T::class.java).get(this) as? T
fun Any.getFirstFieldByExactTypeOrNull(type: Class<*>?): Any? = runCatchingOrNull {
javaClass.findFirstFieldByExactTypeOrNull(type)?.get(this)
}
@Suppress("UNCHECKED_CAST")
fun <T> Any.getFirstFieldByExactTypeOrNullAs(type: Class<*>?) =
getFirstFieldByExactTypeOrNull(type) as? T
inline fun <reified T> Any.getFirstFieldByExactTypeOrNull() =
getFirstFieldByExactTypeOrNull(T::class.java) as? T
inline fun ClassLoader.findDexClassLoader(crossinline delegator: (BaseDexClassLoader) -> BaseDexClassLoader = { x -> x }): BaseDexClassLoader? {
var classLoader = this
while (classLoader !is BaseDexClassLoader) {
if (classLoader.parent != null) classLoader = classLoader.parent
else return null
}
return delegator(classLoader)
}
inline fun ClassLoader.allClassesList(crossinline delegator: (BaseDexClassLoader) -> BaseDexClassLoader = { x -> x }): List<String> {
return findDexClassLoader(delegator)?.getObjectField("pathList")
?.getObjectFieldAs<Array<Any>>("dexElements")
?.flatMap {
it.getObjectField("dexFile")?.callMethodAs<Enumeration<String>>("entries")?.toList()
.orEmpty()
}.orEmpty()
}
val Member.isStatic: Boolean
inline get() = Modifier.isStatic(modifiers)
val Member.isFinal: Boolean
inline get() = Modifier.isFinal(modifiers)
val Member.isPublic: Boolean
inline get() = Modifier.isPublic(modifiers)
val Member.isNotStatic: Boolean
inline get() = !isStatic
val Member.isAbstract: Boolean
inline get() = Modifier.isAbstract(modifiers)
val Member.isPrivate: Boolean
inline get() = Modifier.isPrivate(modifiers)
val Class<*>.isAbstract: Boolean
inline get() = !isPrimitive && Modifier.isAbstract(modifiers)
val Class<*>.isFinal: Boolean
inline get() = !isPrimitive && Modifier.isFinal(modifiers)
@@ -0,0 +1,90 @@
@file:Suppress("unused")
package me.iacn.biliroaming.utils
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import de.robv.android.xposed.XposedBridge
import me.iacn.biliroaming.BiliBiliPackage
import me.iacn.biliroaming.Constant.TAG
import android.util.Log as ALog
object Log {
private val handler by lazy { Handler(Looper.getMainLooper()) }
private var toast: Toast? = null
fun toast(msg: String, force: Boolean = false, duration: Int = Toast.LENGTH_SHORT, alsoLog: Boolean = true) {
if (!force && !sPrefs.getBoolean("show_info", true)) return
handler.post {
val inst = runCatchingOrNull { BiliBiliPackage.instance } ?: return@post
inst.toastHelperClass?.runCatchingOrNull {
callStaticMethod(inst.cancelShowToast())
callStaticMethod(
inst.showToast(),
currentContext,
"哔哩漫游:$msg",
duration
)
Unit
} ?: run {
toast?.cancel()
toast = Toast.makeText(currentContext, "", duration).apply {
setText("哔哩漫游:$msg")
show()
}
}
}
if (alsoLog) w(msg)
}
@JvmStatic
private fun doLog(f: (String, String) -> Int, obj: Any?, toXposed: Boolean = false) {
val str = if (obj is Throwable) ALog.getStackTraceString(obj) else obj.toString()
if (str.length > maxLength) {
val chunkCount: Int = str.length / maxLength
for (i in 0..chunkCount) {
val max: Int = maxLength * (i + 1)
if (max >= str.length) {
doLog(f, str.substring(maxLength * i))
} else {
doLog(f, str.substring(maxLength * i, max))
}
}
} else {
f(TAG, str)
if (toXposed)
XposedBridge.log("$TAG : $str")
}
}
@JvmStatic
fun d(obj: Any?) {
doLog(ALog::d, obj)
}
@JvmStatic
fun i(obj: Any?) {
doLog(ALog::i, obj)
}
@JvmStatic
fun e(obj: Any?) {
doLog(ALog::e, obj, true)
}
@JvmStatic
fun v(obj: Any?) {
doLog(ALog::v, obj)
}
@JvmStatic
fun w(obj: Any?) {
doLog(ALog::w, obj)
}
private const val maxLength = 3000
}
@@ -0,0 +1,52 @@
package me.iacn.biliroaming.utils
import android.graphics.Canvas
import android.graphics.Paint
import android.text.TextPaint
import android.text.style.ReplacementSpan
class StrokeSpan(
private val fillColor: Int,
private val strokeColor: Int,
private val strokeWidth: Float
) : ReplacementSpan() {
private fun fillPaint(paint: Paint): TextPaint =
TextPaint(paint).apply {
style = Paint.Style.FILL
color = fillColor
}
private fun stokePaint(paint: Paint): TextPaint =
TextPaint(paint).apply {
style = Paint.Style.STROKE
color = strokeColor
strokeWidth = this@StrokeSpan.strokeWidth
}
override fun getSize(
p0: Paint,
p1: CharSequence?,
p2: Int,
p3: Int,
p4: Paint.FontMetricsInt?
): Int {
return p0.measureText(p1, p2, p3).toInt()
}
override fun draw(
canvas: Canvas,
text: CharSequence?,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
text ?: return
canvas.drawText(text, start, end, x, y.toFloat(), fillPaint(paint))
if (strokeWidth > 0)
canvas.drawText(text, start, end, x, y.toFloat(), stokePaint(paint))
}
}
@@ -0,0 +1,204 @@
package me.iacn.biliroaming.utils
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapFactory.Options
import me.iacn.biliroaming.R
import me.iacn.biliroaming.XposedInit.Companion.moduleRes
import org.json.JSONArray
import org.json.JSONObject
import java.io.*
import java.net.URL
import java.nio.ByteBuffer
import java.util.zip.GZIPInputStream
class TrieNode<V>(val key: Char, val level: Int = 0) {
private val children = hashMapOf<Char, TrieNode<V>>()
val isLeaf get() = value != null
var value: V? = null
fun getOrAddChild(k: Char) = children.computeIfAbsent(k) { TrieNode(k, level + 1) }
fun child(k: Char) = children[k]
}
class Trie<T> {
private val root = TrieNode<T>(key = '\u0000')
fun add(w: String, value: T) {
if (w.isEmpty()) return
var p = root
for (c in w.toCharArray())
p = p.getOrAddChild(c)
p.value = value
}
fun bestMatch(sen: CharArray): TrieNode<T>? {
var node: TrieNode<T> = root
var leaf: TrieNode<T>? = null
for (c in sen) {
node = node.child(c) ?: break
if (node.isLeaf) leaf = node
}
return leaf
}
}
class Dictionary(
private val chars: Map<Char, Char>,
private val dict: Trie<String>,
private val maxLen: Int
) {
private fun convert(reader: Reader, writer: Writer) {
val `in` = PushbackReader(reader.buffered(), maxLen)
val buf = CharArray(maxLen)
var len: Int
while (true) {
len = `in`.read(buf)
if (len == -1) break
val node = dict.bestMatch(buf)
if (node != null) {
val offset = node.level
node.value?.let { writer.write(it) }
`in`.unread(buf, offset, len - offset)
} else {
`in`.unread(buf, 0, len)
val ch = `in`.read().toChar()
writer.write(chars.getOrDefault(ch, ch).code)
}
}
}
fun convert(str: String) = StringWriter().also {
convert(str.reader(), it)
}.toString()
companion object {
private const val SHARP = '#'
private const val EQUAL = '='
fun loadDictionary(mappingFile: File): Dictionary {
val charMap = HashMap<Char, Char>(4096)
val dict = Trie<String>()
var maxLen = 2
mappingFile.bufferedReader().useLines { lines ->
lines.filterNot { it.isBlank() || it.trimStart().startsWith(SHARP) }
.map { it.split(EQUAL, limit = 2) }.filter { it.size == 2 }.forEach { (k, v) ->
if (k.length == 1 && v.length == 1) {
charMap[k[0]] = v[0]
} else {
maxLen = k.length.coerceAtLeast(maxLen)
dict.add(k, v)
}
}
}
return Dictionary(charMap, dict, maxLen)
}
}
}
object SubtitleHelper {
private val dictFile by lazy { File(currentContext.filesDir, "t2cn.txt") }
private val dictionary by lazy { Dictionary.loadDictionary(dictFile) }
private const val dictUrl =
"https://archive.biliimg.com/bfs/archive/566adec17e127bf92aed21832db0206ccecc8caa.png"
// !!! Do not remove symbol '\' for "\}", Android need it
@Suppress("RegExpRedundantEscape")
private val noStyleRegex =
Regex("""\{\\?\\an\d+\}|<font\s[^>]*>|<\\?/font>|<i>|<\\?/i>|<b>|<\\?/b>|<u>|<\\?/u>""")
val dictExist get() = dictFile.isFile
@Synchronized
fun downloadDict(): Boolean {
if (dictExist) return true
runCatching {
val buffer = URL(dictUrl).openStream().buffered().use {
val options = Options().apply { inPreferredConfig = Bitmap.Config.RGB_565 }
val bitmap = BitmapFactory.decodeStream(it, null, options)
ByteBuffer.allocate(bitmap!!.byteCount).apply {
bitmap.let { b -> b.copyPixelsToBuffer(this); b.recycle() }
rewind()
}
}
val bytes = ByteArray(buffer.int).also { buffer.get(it) }
dictFile.outputStream().use { o ->
GZIPInputStream(bytes.inputStream()).use { it.copyTo(o) }
}
}.onSuccess {
return true
}.onFailure {
Log.e(it)
dictFile.delete()
}
return false
}
fun convert(json: String): String {
val subJson = JSONObject(json)
var subBody = subJson.optJSONArray("body") ?: return json
val subText = subBody.asSequence<JSONObject>().map { it.optString("content") }
.joinToString("\u0000").run {
// Remove srt style, bilibili not support it
if (contains("\\an") || contains("<font")
|| contains("<i>") || contains("<b>") || contains("<u>")
) replace(noStyleRegex, "") else this
}
val converted = dictionary.convert(subText)
val lines = converted.split('\u0000')
subBody.asSequence<JSONObject>().zip(lines.asSequence()).forEach { (obj, line) ->
obj.put("content", line)
}
subBody = subBody.appendInfo(moduleRes.getString(R.string.subtitle_append_info))
return subJson.apply {
put("body", subBody)
}.toString()
}
fun errorResponse(content: String) = JSONObject().apply {
put("body", JSONArray().apply {
put(JSONObject().apply {
put("from", 0)
put("location", 2)
put("to", 9999)
put("content", content)
})
})
}.toString()
private fun JSONArray.appendInfo(content: String): JSONArray {
if (length() == 0) return this
val firstLine = optJSONObject(0)
?: return this
val lastLine = optJSONObject(length() - 1)
?: return this
val firstFrom = firstLine.optDouble("from")
.takeIf { !it.isNaN() } ?: return this
val lastTo = lastLine.optDouble("to")
.takeIf { !it.isNaN() } ?: return this
val minDuration = 1.0
val maxDuration = 5.0
val interval = 0.3
val appendStart = firstFrom >= minDuration + interval
val from = if (appendStart) 0.0 else lastTo + interval
val to = if (appendStart) {
from + (firstFrom - interval).coerceAtMost(maxDuration)
} else from + maxDuration
val info = JSONObject().apply {
put("from", from)
put("location", 2)
put("to", to)
put("content", content)
}
return if (appendStart) {
JSONArray().apply {
put(info)
for (jo in this@appendInfo) {
put(jo)
}
}
} else apply { put(info) }
}
}
@@ -0,0 +1,140 @@
package me.iacn.biliroaming.utils
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.future.future
import me.iacn.biliroaming.BiliBiliPackage.Companion.instance
import me.iacn.biliroaming.R
import me.iacn.biliroaming.UGCPlayViewReply
import me.iacn.biliroaming.XposedInit
import java.util.concurrent.CompletableFuture
import java.util.concurrent.TimeUnit
object UposReplaceHelper {
private val aliHost = string(R.string.ali_host)
private val cosHost = string(R.string.cos_host)
private val hwHost = string(R.string.hw_host)
private val aliovHost = string(R.string.aliov_host)
private val hwovHost = string(R.string.hwov_host)
private val hkBcacheHost = string(R.string.hk_bcache_host)
val isLocatedCn by lazy {
(runCatchingOrNull { XposedInit.country.get(5L, TimeUnit.SECONDS) } ?: "cn") == "cn"
}
val forceUpos = sPrefs.getBoolean("force_upos", false)
val enablePcdnBlock = sPrefs.getBoolean("block_pcdn", false)
val enableLivePcdnBlock = sPrefs.getBoolean("block_pcdn_live", false)
private lateinit var videoUposList: CompletableFuture<List<String>>
private val mainVideoUpos =
sPrefs.getString("upos_host", null) ?: if (isLocatedCn) hwHost else aliovHost
private val serverList = XposedInit.moduleRes.getStringArray(R.array.upos_values)
private val extraVideoUposList = when (serverList.indexOf(mainVideoUpos)) {
in 1..3 -> listOf(hwHost, cosHost)
in 5..7 -> listOf(hwHost, aliHost)
in 8..15 -> listOf(aliHost, cosHost)
else -> listOf(aliHost, hkBcacheHost)
}
private val videoUposBase by lazy {
runCatchingOrNull { videoUposList.get(500L, TimeUnit.MILLISECONDS) }?.get(0)
?: mainVideoUpos
}
val videoUposBackups by lazy {
runCatchingOrNull { videoUposList.get(500L, TimeUnit.MILLISECONDS) }?.subList(1, 3)
?: extraVideoUposList
}
const val liveUpos = "c1--cn-gotcha01.bilivideo.com"
val enableUposReplace = (mainVideoUpos != "\$1")
private val overseaVideoUposRegex by lazy {
Regex("""(akamai|(ali|hw|cos)\w*ov|hk-eq-bcache|bstar1)""")
}
private val urlBwRegex by lazy { Regex("""(bw=[^&]*)""") }
private val ipPCdnRegex by lazy { Regex("""^https?://\d{1,3}\.\d{1,3}""") }
val gotchaRegex by lazy { Regex("""https?://\w*--\w*-gotcha\d*\.bilivideo""") }
fun initVideoUposList(mClassLoader: ClassLoader) {
videoUposList = MainScope().future(Dispatchers.IO) {
val bCacheRegex = Regex("""cn-.*\.bilivideo""")
mutableListOf(mainVideoUpos).apply {
// 8K video sample, without area limitation, reply probably contains Mirror CDN
val playViewReply = instance.playViewReqClass?.new()?.apply {
callMethod("setAid", 355749246L)
callMethod("setCid", 1115447032L)
callMethod("setQn", 127)
callMethod("setFnval", 4048)
callMethod("setFourk", true)
callMethod("setForceHost", 2)
}?.let { playViewReqUgc ->
instance.playURLMossClass?.new()?.callMethod("playView", playViewReqUgc)
?.callMethodAs<ByteArray>("toByteArray")?.let {
UGCPlayViewReply.parseFrom(it)
}
}
val officialList = playViewReply?.videoInfo?.let { info ->
mutableListOf<String>().apply {
info.streamListList?.forEach { stream ->
add(stream.dashVideo.baseUrl)
addAll(stream.dashVideo.backupUrlList)
}
info.dashAudioList?.forEach { dashItem ->
add(dashItem.baseUrl)
addAll(dashItem.backupUrlList)
}
}
}?.mapNotNull { Uri.parse(it).encodedAuthority }?.distinct()
?.filter { !it.isPCdnUpos() }.orEmpty()
addAll(officialList.filter { !(it.contains(bCacheRegex) || it == mainVideoUpos) }
.ifEmpty { officialList })
addAll(extraVideoUposList)
}.also { hookTf(mClassLoader) }
}
}
fun String.isPCdnUpos() =
contains("szbdyd.com") || contains(".mcdn.bilivideo") || contains(ipPCdnRegex)
fun String.isOverseaUpos() = isLocatedCn == contains(overseaVideoUposRegex)
fun String.isNeedReplaceVideoUpos() =
if (contains(".mcdn.bilivideo") || contains(ipPCdnRegex)) {
// IP:Port type PCDN currently only exists in Live and Thai Video.
// Cannot simply replace IP:Port or 'mcdn.bilivideo' like PCDN's host
false
} else {
// only 'szbdyd.com' like PCDN can be replace
(forceUpos && startsWith("http")) || (enablePcdnBlock && contains("szbdyd.com")) || isOverseaUpos()
}
fun String.replaceUpos(
upos: String = videoUposBase, needReplace: Boolean = true
): String {
fun String.replaceUposBw(): String = replace(urlBwRegex, "bw=1280000")
return if (needReplace) {
val uri = Uri.parse(this)
val newUpos = uri.getQueryParameter("xy_usource") ?: upos
uri.replaceUpos(newUpos).toString().replaceUposBw()
} else replaceUposBw()
}
fun Uri.replaceUpos(upos: String): Uri = buildUpon().authority(upos).build()
private fun hookTf(mClassLoader: ClassLoader) {
if (!(enablePcdnBlock || forceUpos)) return
// fake grpc TF header then only reply with mirror type playurl
"com.bilibili.lib.moss.utils.RuntimeHelper".from(mClassLoader)
?.hookAfterMethod("tf") { param ->
val result = param.result
if (result.callMethodOrNullAs<Int>("getNumber") != 0) return@hookAfterMethod
result.javaClass.callStaticMethodOrNull("forNumber", 1)?.let {
param.result = it
}
}
}
private fun string(resId: Int) = XposedInit.moduleRes.getString(resId)
}
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more