commit
d5d3e61e30
147 files changed
+25311
No files matched your search
@@ -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
|
||||||
@@ -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: 该功能可能的实现方式,或者其他已经实现该功能的应用等
|
||||||
@@ -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: 爱发电等
|
||||||
@@ -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/"
|
||||||
@@ -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
|
||||||
@@ -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
@@ -0,0 +1,11 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
*.apk
|
||||||
|
*.jar
|
||||||
|
/.kotlin
|
||||||
@@ -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
|
||||||
@@ -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>.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img width="160" src="imgs/icon.png" alt="logo">
|
||||||
|
|
||||||
|
Logo来自[哔哩哔哩漫游娘](https://www.weibo.com/p/230418139a6f1100102vlj6)
|
||||||
|
|
||||||
|
[](https://github.com/yujincheng08/BiliRoaming/actions)
|
||||||
|
[](https://qun.qq.com/qqweb/qunpro/share?_wv=3&_wwv=128&inviteCode=NVoD5&from=246610&biz=ka)
|
||||||
|
[](https://t.me/biliroaming)
|
||||||
|
[](https://github.com/yujincheng08/BiliRoaming/releases/latest)
|
||||||
|
[](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)
|
||||||
|

|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/build
|
||||||
|
/release
|
||||||
|
/.cxx
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Vendored
+41
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
generated
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
});
|
||||||
@@ -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
Reference in new issue
Block a user