Clean up the project structure and keep only the kernel
This commit is contained in:
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1 +0,0 @@
|
|||||||
*.bat eol=crlf
|
|
||||||
5
.github/FUNDING.yml
vendored
5
.github/FUNDING.yml
vendored
@@ -1,5 +0,0 @@
|
|||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
github: tiann
|
|
||||||
patreon: weishu
|
|
||||||
custom: https://vxposed.com/donate.html
|
|
||||||
33
.github/ISSUE_TEMPLATE/add_device.yml
vendored
33
.github/ISSUE_TEMPLATE/add_device.yml
vendored
@@ -1,33 +0,0 @@
|
|||||||
name: Contribute to Unofficially Supported Device
|
|
||||||
description: Add your device kernel source to KernelSU's Unofficially Supported Device List
|
|
||||||
title: "[Add Device]: "
|
|
||||||
labels: ["add-device"]
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for supporting KernelSU!
|
|
||||||
- type: input
|
|
||||||
id: repo-url
|
|
||||||
attributes:
|
|
||||||
label: Repository URL
|
|
||||||
description: Your repository URL
|
|
||||||
placeholder: https://github.com/tiann/KernelSU
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: input
|
|
||||||
id: device
|
|
||||||
attributes:
|
|
||||||
label: Device
|
|
||||||
description: Please describe the device maintained by you.
|
|
||||||
placeholder: GKI 2.0 Device
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: checkboxes
|
|
||||||
id: terms
|
|
||||||
attributes:
|
|
||||||
label: Code of Conduct
|
|
||||||
description: By submitting this issue, you should be the maintainer of the repository.
|
|
||||||
options:
|
|
||||||
- label: I'm the maintainer of this repository
|
|
||||||
required: true
|
|
||||||
72
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
72
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,72 +0,0 @@
|
|||||||
name: Bug report
|
|
||||||
description: Create a report to help us improve KernelSU
|
|
||||||
labels: [Bug]
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: checkboxes
|
|
||||||
attributes:
|
|
||||||
label: Please check before submitting an issue
|
|
||||||
options:
|
|
||||||
- label: I have searched the issues and haven't found anything relevant
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- label: I will upload bugreport file in KernelSU Manager - Settings - Report log
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- label: I know how to reproduce the issue which may not be specific to my device
|
|
||||||
required: false
|
|
||||||
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Describe the bug
|
|
||||||
description: A clear and concise description of what the bug is
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: To Reproduce
|
|
||||||
description: Steps to reproduce the behaviour
|
|
||||||
placeholder: |
|
|
||||||
- 1. Go to '...'
|
|
||||||
- 2. Click on '....'
|
|
||||||
- 3. Scroll down to '....'
|
|
||||||
- 4. See error
|
|
||||||
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Expected behavior
|
|
||||||
description: A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Screenshots
|
|
||||||
description: If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Logs
|
|
||||||
description: If applicable, add crash or any other logs to help us figure out the problem.
|
|
||||||
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Device info
|
|
||||||
value: |
|
|
||||||
- Device:
|
|
||||||
- OS Version:
|
|
||||||
- KernelSU Version:
|
|
||||||
- Kernel Version:
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Add any other context about the problem here.
|
|
||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
5
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +0,0 @@
|
|||||||
blank_issues_enabled: false
|
|
||||||
contact_links:
|
|
||||||
- name: Feature Request
|
|
||||||
url: https://github.com/tiann/KernelSU/issues/1705
|
|
||||||
about: "We do not accept external Feature Requests, see this link for more details."
|
|
||||||
11
.github/ISSUE_TEMPLATE/custom.yml
vendored
11
.github/ISSUE_TEMPLATE/custom.yml
vendored
@@ -1,11 +0,0 @@
|
|||||||
name: Custom issue template
|
|
||||||
description: WARNING! If you are reporting a bug but use this template, the issue will be closed directly.
|
|
||||||
title: '[Custom]'
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
attributes:
|
|
||||||
label: "Describe your problem."
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
64
.github/scripts/build_a12.sh
vendored
64
.github/scripts/build_a12.sh
vendored
@@ -1,64 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
build_from_image() {
|
|
||||||
export TITLE
|
|
||||||
TITLE=kernel-aarch64-${1//Image-/}
|
|
||||||
echo "[+] title: $TITLE"
|
|
||||||
|
|
||||||
export PATCH_LEVEL
|
|
||||||
PATCH_LEVEL=$(echo "$1" | awk -F_ '{ print $2}')
|
|
||||||
echo "[+] patch level: $PATCH_LEVEL"
|
|
||||||
|
|
||||||
echo '[+] Download prebuilt ramdisk'
|
|
||||||
GKI_URL=https://dl.google.com/android/gki/gki-certified-boot-android12-5.10-"${PATCH_LEVEL}"_r1.zip
|
|
||||||
FALLBACK_URL=https://dl.google.com/android/gki/gki-certified-boot-android12-5.10-2023-01_r1.zip
|
|
||||||
status=$(curl -sL -w "%{http_code}" "$GKI_URL" -o /dev/null)
|
|
||||||
if [ "$status" = "200" ]; then
|
|
||||||
curl -Lo gki-kernel.zip "$GKI_URL"
|
|
||||||
else
|
|
||||||
echo "[+] $GKI_URL not found, using $FALLBACK_URL"
|
|
||||||
curl -Lo gki-kernel.zip "$FALLBACK_URL"
|
|
||||||
fi
|
|
||||||
unzip gki-kernel.zip && rm gki-kernel.zip
|
|
||||||
|
|
||||||
echo '[+] Unpack prebuilt boot.img'
|
|
||||||
BOOT_IMG=$(find . -maxdepth 1 -name "boot*.img")
|
|
||||||
$UNPACK_BOOTIMG --boot_img="$BOOT_IMG"
|
|
||||||
rm "$BOOT_IMG"
|
|
||||||
|
|
||||||
echo '[+] Building Image.gz'
|
|
||||||
$GZIP -n -k -f -9 Image >Image.gz
|
|
||||||
|
|
||||||
echo '[+] Building boot.img'
|
|
||||||
$MKBOOTIMG --header_version 4 --kernel Image --output boot.img --ramdisk out/ramdisk --os_version 12.0.0 --os_patch_level "${PATCH_LEVEL}"
|
|
||||||
$AVBTOOL add_hash_footer --partition_name boot --partition_size $((64 * 1024 * 1024)) --image boot.img --algorithm SHA256_RSA2048 --key ../kernel-build-tools/linux-x86/share/avb/testkey_rsa2048.pem
|
|
||||||
|
|
||||||
echo '[+] Building boot-gz.img'
|
|
||||||
$MKBOOTIMG --header_version 4 --kernel Image.gz --output boot-gz.img --ramdisk out/ramdisk --os_version 12.0.0 --os_patch_level "${PATCH_LEVEL}"
|
|
||||||
$AVBTOOL add_hash_footer --partition_name boot --partition_size $((64 * 1024 * 1024)) --image boot-gz.img --algorithm SHA256_RSA2048 --key ../kernel-build-tools/linux-x86/share/avb/testkey_rsa2048.pem
|
|
||||||
|
|
||||||
echo '[+] Building boot-lz4.img'
|
|
||||||
$MKBOOTIMG --header_version 4 --kernel Image.lz4 --output boot-lz4.img --ramdisk out/ramdisk --os_version 12.0.0 --os_patch_level "${PATCH_LEVEL}"
|
|
||||||
$AVBTOOL add_hash_footer --partition_name boot --partition_size $((64 * 1024 * 1024)) --image boot-lz4.img --algorithm SHA256_RSA2048 --key ../kernel-build-tools/linux-x86/share/avb/testkey_rsa2048.pem
|
|
||||||
|
|
||||||
echo '[+] Compress images'
|
|
||||||
for image in boot*.img; do
|
|
||||||
$GZIP -n -f -9 "$image"
|
|
||||||
mv "$image".gz "${1//Image-/}"-"$image".gz
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "[+] Images to upload"
|
|
||||||
find . -type f -name "*.gz"
|
|
||||||
|
|
||||||
# find . -type f -name "*.gz" -exec python3 "$GITHUB_WORKSPACE"/KernelSU/scripts/ksubot.py {} +
|
|
||||||
}
|
|
||||||
|
|
||||||
for dir in Image*; do
|
|
||||||
if [ -d "$dir" ]; then
|
|
||||||
echo "----- Building $dir -----"
|
|
||||||
cd "$dir"
|
|
||||||
build_from_image "$dir"
|
|
||||||
cd ..
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
43
.github/scripts/build_a13.sh
vendored
43
.github/scripts/build_a13.sh
vendored
@@ -1,43 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
build_from_image() {
|
|
||||||
export TITLE
|
|
||||||
TITLE=kernel-aarch64-${1//Image-/}
|
|
||||||
|
|
||||||
echo "[+] title: $TITLE"
|
|
||||||
echo '[+] Building Image.gz'
|
|
||||||
$GZIP -n -k -f -9 Image >Image.gz
|
|
||||||
|
|
||||||
echo '[+] Building boot.img'
|
|
||||||
$MKBOOTIMG --header_version 4 --kernel Image --output boot.img
|
|
||||||
$AVBTOOL add_hash_footer --partition_name boot --partition_size $((64 * 1024 * 1024)) --image boot.img --algorithm SHA256_RSA2048 --key ../kernel-build-tools/linux-x86/share/avb/testkey_rsa2048.pem
|
|
||||||
|
|
||||||
echo '[+] Building boot-gz.img'
|
|
||||||
$MKBOOTIMG --header_version 4 --kernel Image.gz --output boot-gz.img
|
|
||||||
$AVBTOOL add_hash_footer --partition_name boot --partition_size $((64 * 1024 * 1024)) --image boot-gz.img --algorithm SHA256_RSA2048 --key ../kernel-build-tools/linux-x86/share/avb/testkey_rsa2048.pem
|
|
||||||
|
|
||||||
echo '[+] Building boot-lz4.img'
|
|
||||||
$MKBOOTIMG --header_version 4 --kernel Image.lz4 --output boot-lz4.img
|
|
||||||
$AVBTOOL add_hash_footer --partition_name boot --partition_size $((64 * 1024 * 1024)) --image boot-lz4.img --algorithm SHA256_RSA2048 --key ../kernel-build-tools/linux-x86/share/avb/testkey_rsa2048.pem
|
|
||||||
|
|
||||||
echo '[+] Compress images'
|
|
||||||
for image in boot*.img; do
|
|
||||||
$GZIP -n -f -9 "$image"
|
|
||||||
mv "$image".gz "${1//Image-/}"-"$image".gz
|
|
||||||
done
|
|
||||||
|
|
||||||
echo '[+] Images to upload'
|
|
||||||
find . -type f -name "*.gz"
|
|
||||||
|
|
||||||
# find . -type f -name "*.gz" -exec python3 "$GITHUB_WORKSPACE"/KernelSU/scripts/ksubot.py {} +
|
|
||||||
}
|
|
||||||
|
|
||||||
for dir in Image*; do
|
|
||||||
if [ -d "$dir" ]; then
|
|
||||||
echo "----- Building $dir -----"
|
|
||||||
cd "$dir"
|
|
||||||
build_from_image "$dir"
|
|
||||||
cd ..
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
60
.github/workflows/add-device.yml
vendored
60
.github/workflows/add-device.yml
vendored
@@ -1,60 +0,0 @@
|
|||||||
name: handle-add-device-issue
|
|
||||||
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [labeled]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
handle-add-device:
|
|
||||||
if: github.event.label.name == 'add-device'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
ISSUE_CONTENT: ${{ github.event.issue.body }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Parse issue body
|
|
||||||
id: handle-add-device
|
|
||||||
run: |
|
|
||||||
python3 scripts/add_device_handler.py website/docs/repos.json || true
|
|
||||||
- name: Commit
|
|
||||||
if: steps.handle-add-device.outputs.success == 'true'
|
|
||||||
run: |
|
|
||||||
git config --local user.name "GitHub Actions"
|
|
||||||
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add website/docs/repos.json
|
|
||||||
git commit -m "add device: ${{ steps.handle-add-device.outputs.device }}"
|
|
||||||
- name: Make pull request
|
|
||||||
if: steps.handle-add-device.outputs.success == 'true'
|
|
||||||
id: cpr
|
|
||||||
uses: peter-evans/create-pull-request@v7
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
commit-message: "[add device]: ${{ steps.handle-add-device.outputs.device }}"
|
|
||||||
title: "[add device]: ${{ steps.handle-add-device.outputs.device }}"
|
|
||||||
body: |
|
|
||||||
${{ steps.handle-add-device.outputs.device }} has been added to the website.
|
|
||||||
Related issue: ${{ github.event.issue.html_url }}
|
|
||||||
branch: "add-device-${{ github.event.issue.number }}"
|
|
||||||
labels: add-device
|
|
||||||
delete-branch: true
|
|
||||||
sign-commits: true
|
|
||||||
- name: Check outputs
|
|
||||||
if: ${{ steps.cpr.outputs.pull-request-number }}
|
|
||||||
run: |
|
|
||||||
echo "Pull Request Number - ${{ steps.cpr.outputs.pull-request-number }}"
|
|
||||||
echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}"
|
|
||||||
- uses: Kernel-SU/actions-comment-on-issue@master
|
|
||||||
if: ${{ steps.cpr.outputs.pull-request-number }}
|
|
||||||
with:
|
|
||||||
message: "Automatically created pull request: ${{ steps.cpr.outputs.pull-request-url }}"
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- uses: Kernel-SU/actions-comment-on-issue@master
|
|
||||||
if: steps.handle-add-device.outputs.success != 'true'
|
|
||||||
with:
|
|
||||||
message: "Cannot create pull request. Please check the issue content. Or you can create a pull request manually."
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: close issue
|
|
||||||
uses: peter-evans/close-issue@v3
|
|
||||||
with:
|
|
||||||
issue-number: ${{ github.event.issue.number }}
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
137
.github/workflows/avd-kernel.yml
vendored
137
.github/workflows/avd-kernel.yml
vendored
@@ -1,137 +0,0 @@
|
|||||||
name: GKI Kernel Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
version_name:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
description: >
|
|
||||||
With SUBLEVEL of kernel,
|
|
||||||
for example: android12-5.10.66
|
|
||||||
arch:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
description: >
|
|
||||||
Build arch: aarch64/x86_64
|
|
||||||
debug:
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
manifest_name:
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
description: >
|
|
||||||
Local repo manifest xml path,
|
|
||||||
typically for AVD kernel build.
|
|
||||||
secrets:
|
|
||||||
BOOT_SIGN_KEY:
|
|
||||||
required: false
|
|
||||||
CHAT_ID:
|
|
||||||
required: false
|
|
||||||
BOT_TOKEN:
|
|
||||||
required: false
|
|
||||||
MESSAGE_THREAD_ID:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build ${{ inputs.version_name }}
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
steps:
|
|
||||||
- name: Maximize build space
|
|
||||||
uses: easimon/maximize-build-space@master
|
|
||||||
with:
|
|
||||||
root-reserve-mb: 8192
|
|
||||||
temp-reserve-mb: 2048
|
|
||||||
remove-dotnet: 'true'
|
|
||||||
remove-android: 'true'
|
|
||||||
remove-haskell: 'true'
|
|
||||||
remove-codeql: 'true'
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
path: KernelSU
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup need_upload
|
|
||||||
id: need_upload
|
|
||||||
run: |
|
|
||||||
if [ ! -z "${{ secrets.BOT_TOKEN }}" ]; then
|
|
||||||
echo "UPLOAD=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "UPLOAD=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Setup kernel source
|
|
||||||
run: |
|
|
||||||
echo "Free space:"
|
|
||||||
df -h
|
|
||||||
cd $GITHUB_WORKSPACE
|
|
||||||
sudo apt-get install repo -y
|
|
||||||
mkdir android-kernel && cd android-kernel
|
|
||||||
repo init --depth=1 -u https://android.googlesource.com/kernel/manifest -m "$GITHUB_WORKSPACE/KernelSU/.github/manifests/${{ inputs.manifest_name }}" --repo-rev=v2.16
|
|
||||||
repo --version
|
|
||||||
repo --trace sync -c -j$(nproc --all) --no-tags
|
|
||||||
df -h
|
|
||||||
|
|
||||||
- name: Setup KernelSU
|
|
||||||
env:
|
|
||||||
PATCH_PATH: ${{ inputs.patch_path }}
|
|
||||||
IS_DEBUG_KERNEL: ${{ inputs.debug }}
|
|
||||||
run: |
|
|
||||||
cd $GITHUB_WORKSPACE/android-kernel
|
|
||||||
echo "[+] KernelSU setup"
|
|
||||||
GKI_ROOT=$(pwd)
|
|
||||||
echo "[+] GKI_ROOT: $GKI_ROOT"
|
|
||||||
echo "[+] Copy KernelSU driver to $GKI_ROOT/common/drivers"
|
|
||||||
ln -sf $GITHUB_WORKSPACE/KernelSU/kernel $GKI_ROOT/common/drivers/kernelsu
|
|
||||||
echo "[+] Add KernelSU driver to Makefile"
|
|
||||||
DRIVER_MAKEFILE=$GKI_ROOT/common/drivers/Makefile
|
|
||||||
DRIVER_KCONFIG=$GKI_ROOT/common/drivers/Kconfig
|
|
||||||
grep -q "kernelsu" "$DRIVER_MAKEFILE" || printf "\nobj-\$(CONFIG_KSU) += kernelsu/\n" >> "$DRIVER_MAKEFILE"
|
|
||||||
grep -q "kernelsu" "$DRIVER_KCONFIG" || sed -i "/endmenu/i\\source \"drivers/kernelsu/Kconfig\"" "$DRIVER_KCONFIG"
|
|
||||||
echo "[+] Apply KernelSU patches"
|
|
||||||
cd $GKI_ROOT/common/ && git apply $GITHUB_WORKSPACE/KernelSU/.github/patches/$PATCH_PATH/*.patch || echo "[-] No patch found"
|
|
||||||
|
|
||||||
if [ "$IS_DEBUG_KERNEL" = "true" ]; then
|
|
||||||
echo "[+] Enable debug features for kernel"
|
|
||||||
printf "\nccflags-y += -DCONFIG_KSU_DEBUG\n" >> $GITHUB_WORKSPACE/KernelSU/kernel/Makefile
|
|
||||||
fi
|
|
||||||
repo status
|
|
||||||
echo "[+] KernelSU setup done."
|
|
||||||
cd $GITHUB_WORKSPACE/KernelSU
|
|
||||||
VERSION=$(($(git rev-list --count HEAD) + 10200))
|
|
||||||
echo "VERSION: $VERSION"
|
|
||||||
echo "kernelsu_version=$VERSION" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Make working directory clean to avoid dirty
|
|
||||||
working-directory: android-kernel
|
|
||||||
run: |
|
|
||||||
rm common/android/abi_gki_protected_exports_* || echo "No protected exports!"
|
|
||||||
git config --global user.email "bot@kernelsu.org"
|
|
||||||
git config --global user.name "KernelSUBot"
|
|
||||||
cd common/ && git add -A && git commit -a -m "Add KernelSU"
|
|
||||||
repo status
|
|
||||||
|
|
||||||
- name: Build kernel
|
|
||||||
working-directory: android-kernel
|
|
||||||
run: |
|
|
||||||
if [ ! -z ${{ vars.EXPECTED_SIZE }} ] && [ ! -z ${{ vars.EXPECTED_HASH }} ]; then
|
|
||||||
export KSU_EXPECTED_SIZE=${{ vars.EXPECTED_SIZE }}
|
|
||||||
export KSU_EXPECTED_HASH=${{ vars.EXPECTED_HASH }}
|
|
||||||
fi
|
|
||||||
tools/bazel run --config=fast --config=stamp --lto=thin //common-modules/virtual-device:virtual_device_${{ inputs.arch }}_dist -- --dist_dir=dist
|
|
||||||
NAME=kernel-${{ inputs.arch }}-avd-${{ inputs.version_name }}-${{ env.kernelsu_version }}
|
|
||||||
TARGET_IMAGE=dist/bzImage
|
|
||||||
if [ ! -e $TARGET_IMAGE ]; then
|
|
||||||
TARGET_IMAGE=dist/Image
|
|
||||||
fi
|
|
||||||
mv $TARGET_IMAGE $NAME
|
|
||||||
echo "file_path=android-kernel/$NAME" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Upload Kernel
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: kernel-${{ inputs.arch }}-avd-${{ inputs.version_name }}-${{ env.kernelsu_version }}
|
|
||||||
path: "${{ env.file_path }}"
|
|
||||||
74
.github/workflows/build-lkm.yml
vendored
74
.github/workflows/build-lkm.yml
vendored
@@ -1,74 +0,0 @@
|
|||||||
name: Build LKM for KernelSU
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
upload:
|
|
||||||
required: true
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
description: "Whether to upload to branch"
|
|
||||||
secrets:
|
|
||||||
# username:github_pat
|
|
||||||
TOKEN:
|
|
||||||
required: true
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
upload:
|
|
||||||
required: true
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
description: "Whether to upload to branch"
|
|
||||||
jobs:
|
|
||||||
build-lkm:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- version: "android12-5.10"
|
|
||||||
sub_level: 233
|
|
||||||
os_patch_level: 2025-02
|
|
||||||
- version: "android13-5.10"
|
|
||||||
sub_level: 234
|
|
||||||
os_patch_level: 2025-03
|
|
||||||
- version: "android13-5.15"
|
|
||||||
sub_level: 178
|
|
||||||
os_patch_level: 2025-03
|
|
||||||
- version: "android14-5.15"
|
|
||||||
sub_level: 178
|
|
||||||
os_patch_level: 2025-03
|
|
||||||
- version: "android14-6.1"
|
|
||||||
sub_level: 129
|
|
||||||
os_patch_level: 2025-04
|
|
||||||
- version: "android15-6.6"
|
|
||||||
sub_level: 82
|
|
||||||
os_patch_level: 2025-04
|
|
||||||
# uses: ./.github/workflows/gki-kernel-mock.yml when debugging
|
|
||||||
uses: ./.github/workflows/gki-kernel.yml
|
|
||||||
with:
|
|
||||||
version: ${{ matrix.version }}
|
|
||||||
version_name: ${{ matrix.version }}.${{ matrix.sub_level }}
|
|
||||||
tag: ${{ matrix.version }}-${{ matrix.os_patch_level }}
|
|
||||||
os_patch_level: ${{ matrix.os_patch_level }}
|
|
||||||
build_lkm: true
|
|
||||||
|
|
||||||
push-to-branch:
|
|
||||||
needs: [build-lkm]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ inputs.upload }}
|
|
||||||
steps:
|
|
||||||
- name: Download all workflow run artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: bin/
|
|
||||||
merge-multiple: true
|
|
||||||
- name: Push to branch LKM
|
|
||||||
run: |
|
|
||||||
cd bin
|
|
||||||
git config --global init.defaultBranch lkm
|
|
||||||
git init
|
|
||||||
git remote add origin https://${{ secrets.TOKEN }}@github.com/${{ github.repository }}
|
|
||||||
git config --local user.name "github-actions[bot]"
|
|
||||||
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
find . -type f
|
|
||||||
git add .
|
|
||||||
git commit -m "Upload LKM from ${{ github.sha }}" -m "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
|
||||||
git push --force --set-upstream origin lkm
|
|
||||||
266
.github/workflows/build-manager.yml
vendored
266
.github/workflows/build-manager.yml
vendored
@@ -1,266 +0,0 @@
|
|||||||
name: Build Manager
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "main", "ci" ]
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/build-manager.yml'
|
|
||||||
- 'manager/**'
|
|
||||||
- 'kernel/**'
|
|
||||||
- 'userspace/ksud/**'
|
|
||||||
- 'userspace/susfs/**'
|
|
||||||
- 'userspace/kpmmgr/**'
|
|
||||||
pull_request:
|
|
||||||
branches: [ "main" ]
|
|
||||||
paths:
|
|
||||||
- 'manager/**'
|
|
||||||
workflow_call:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
build_lkm:
|
|
||||||
required: true
|
|
||||||
type: choice
|
|
||||||
default: "auto"
|
|
||||||
options:
|
|
||||||
- "true"
|
|
||||||
- "false"
|
|
||||||
- "auto"
|
|
||||||
description: "Whether to build lkm"
|
|
||||||
upload_lkm:
|
|
||||||
required: true
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
description: "Whether to upload lkm"
|
|
||||||
jobs:
|
|
||||||
check-build-lkm:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
build_lkm: ${{ steps.check-build.outputs.build_lkm }}
|
|
||||||
upload_lkm: ${{ steps.check-build.outputs.upload_lkm }}
|
|
||||||
steps:
|
|
||||||
- name: check build
|
|
||||||
id: check-build
|
|
||||||
run: |
|
|
||||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ "${{ inputs.build_lkm }}" != "auto" ]; then
|
|
||||||
kernel_changed="${{ inputs.build_lkm }}"
|
|
||||||
else
|
|
||||||
kernel_changed=true
|
|
||||||
mkdir tmp
|
|
||||||
cd tmp
|
|
||||||
git config --global init.defaultBranch bot
|
|
||||||
git config --global user.name 'Bot'
|
|
||||||
git config --global user.email 'bot@github.shirkneko.io'
|
|
||||||
git init .
|
|
||||||
git remote add origin https://github.com/${{ github.repository }}
|
|
||||||
CURRENT_COMMIT="${{ github.event.head_commit.id }}"
|
|
||||||
git fetch origin $CURRENT_COMMIT --depth=1
|
|
||||||
git fetch origin lkm --depth=1
|
|
||||||
LKM_COMMIT="$(git log --format=%B -n 1 origin/lkm | head -n 1)"
|
|
||||||
LKM_COMMIT="${LKM_COMMIT#Upload LKM from }"
|
|
||||||
LKM_COMMIT=$(echo "$LKM_COMMIT" | tr -d '[:space:]')
|
|
||||||
echo "LKM_COMMIT=$LKM_COMMIT"
|
|
||||||
git fetch origin "$LKM_COMMIT" --depth=1
|
|
||||||
git diff --quiet "$LKM_COMMIT" "$CURRENT_COMMIT" -- kernel :!kernel/setup.sh .github/workflows/build-lkm.yml .github/workflows/build-kernel-*.yml && kernel_changed=false
|
|
||||||
cd ..
|
|
||||||
rm -rf tmp
|
|
||||||
fi
|
|
||||||
if [ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref }}" == 'refs/heads/main' ]; then
|
|
||||||
need_upload=true
|
|
||||||
elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
|
||||||
need_upload="${{ inputs.upload_lkm }}"
|
|
||||||
else
|
|
||||||
need_upload=false
|
|
||||||
fi
|
|
||||||
echo "kernel changed: $kernel_changed"
|
|
||||||
echo "need upload: $need_upload"
|
|
||||||
echo "build_lkm=$kernel_changed" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "upload_lkm=$need_upload" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
build-lkm:
|
|
||||||
needs: check-build-lkm
|
|
||||||
uses: ./.github/workflows/build-lkm.yml
|
|
||||||
if: ${{ needs.check-build-lkm.outputs.build_lkm == 'true' }}
|
|
||||||
with:
|
|
||||||
upload: ${{ needs.check-build-lkm.outputs.upload_lkm == 'true' }}
|
|
||||||
secrets: inherit
|
|
||||||
build-susfs:
|
|
||||||
if: ${{ always() }}
|
|
||||||
needs: [ check-build-lkm, build-lkm ]
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- target: aarch64-linux-android
|
|
||||||
os: ubuntu-latest
|
|
||||||
uses: ./.github/workflows/susfs.yml
|
|
||||||
with:
|
|
||||||
target: ${{ matrix.target }}
|
|
||||||
os: ${{ matrix.os }}
|
|
||||||
|
|
||||||
build-kpmmgr:
|
|
||||||
if: ${{ always() }}
|
|
||||||
needs: [ check-build-lkm, build-lkm ]
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- target: aarch64-linux-android
|
|
||||||
os: ubuntu-latest
|
|
||||||
uses: ./.github/workflows/kpmmgr.yml
|
|
||||||
with:
|
|
||||||
target: ${{ matrix.target }}
|
|
||||||
os: ${{ matrix.os }}
|
|
||||||
|
|
||||||
build-ksud:
|
|
||||||
if: ${{ always() }}
|
|
||||||
needs: [ check-build-lkm, build-lkm ]
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- target: aarch64-linux-android
|
|
||||||
os: ubuntu-latest
|
|
||||||
- target: x86_64-linux-android
|
|
||||||
os: ubuntu-latest
|
|
||||||
uses: ./.github/workflows/ksud.yml
|
|
||||||
with:
|
|
||||||
target: ${{ matrix.target }}
|
|
||||||
os: ${{ matrix.os }}
|
|
||||||
pack_lkm: true
|
|
||||||
pull_lkm: ${{ needs.check-build-lkm.outputs.build_lkm != 'true' }}
|
|
||||||
|
|
||||||
build-manager:
|
|
||||||
if: ${{ always() }}
|
|
||||||
needs: build-ksud
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: ./manager
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup need_upload
|
|
||||||
id: need_upload
|
|
||||||
run: |
|
|
||||||
if [ ! -z "${{ secrets.BOT_TOKEN }}" ]; then
|
|
||||||
echo "UPLOAD=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "UPLOAD=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Write key
|
|
||||||
if: ${{ ( github.event_name != 'pull_request' && github.ref == 'refs/heads/main' ) || github.ref == 'refs/heads/susfs' || github.ref_type == 'tag' }}
|
|
||||||
run: |
|
|
||||||
if [ ! -z "${{ secrets.KEYSTORE }}" ]; then
|
|
||||||
{
|
|
||||||
echo KEYSTORE_PASSWORD='${{ secrets.KEYSTORE_PASSWORD }}'
|
|
||||||
echo KEY_ALIAS='${{ secrets.KEY_ALIAS }}'
|
|
||||||
echo KEY_PASSWORD='${{ secrets.KEY_PASSWORD }}'
|
|
||||||
echo KEYSTORE_FILE='key.jks'
|
|
||||||
} >> gradle.properties
|
|
||||||
echo "${{ secrets.KEYSTORE }}" | base64 -d > key.jks
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Setup Java
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: 21
|
|
||||||
|
|
||||||
- name: Setup Gradle
|
|
||||||
uses: gradle/actions/setup-gradle@v4
|
|
||||||
|
|
||||||
- name: Setup Android SDK
|
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
|
|
||||||
- name: Download arm64 susfs
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: susfs-aarch64-linux-android
|
|
||||||
path: .
|
|
||||||
|
|
||||||
- name: Download arm64 kpmmgr
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: kpmmgr-aarch64-linux-android
|
|
||||||
path: .
|
|
||||||
|
|
||||||
- name: Download arm64 ksud
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ksud-aarch64-linux-android
|
|
||||||
path: .
|
|
||||||
|
|
||||||
- name: Download x86_64 ksud
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ksud-x86_64-linux-android
|
|
||||||
path: .
|
|
||||||
|
|
||||||
- name: Copy ksud to app jniLibs
|
|
||||||
run: |
|
|
||||||
mkdir -p app/src/main/jniLibs/arm64-v8a
|
|
||||||
mkdir -p app/src/main/jniLibs/x86_64
|
|
||||||
cp -f ../aarch64-linux-android/release/zakozako ../manager/app/src/main/jniLibs/arm64-v8a/libzakozako.so
|
|
||||||
cp -f ../x86_64-linux-android/release/zakozako ../manager/app/src/main/jniLibs/x86_64/libzakozako.so
|
|
||||||
|
|
||||||
- name: Copy kpmmgr to app jniLibs
|
|
||||||
run: |
|
|
||||||
mkdir -p app/src/main/jniLibs/arm64-v8a
|
|
||||||
cp -f ../arm64-v8a/kpmmgr ../manager/app/src/main/jniLibs/arm64-v8a/libkpmmgr.so
|
|
||||||
|
|
||||||
- name: Copy susfs to app jniLibs
|
|
||||||
run: |
|
|
||||||
mkdir -p app/src/main/jniLibs/arm64-v8a
|
|
||||||
cp -f ../arm64-v8a/zakozakozako ../manager/app/src/main/jniLibs/arm64-v8a/libzakozakozako.so
|
|
||||||
|
|
||||||
- name: Build with Gradle
|
|
||||||
run: |
|
|
||||||
{
|
|
||||||
echo 'org.gradle.parallel=true'
|
|
||||||
echo 'org.gradle.vfs.watch=true'
|
|
||||||
echo 'org.gradle.jvmargs=-Xmx2048m'
|
|
||||||
echo 'android.native.buildOutput=verbose'
|
|
||||||
} >> gradle.properties
|
|
||||||
sed -i 's/org.gradle.configuration-cache=true//g' gradle.properties
|
|
||||||
./gradlew clean assembleRelease
|
|
||||||
- name: Upload build artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: ${{ ( github.event_name != 'pull_request' && github.ref == 'refs/heads/main' ) || github.ref_type == 'tag' }}
|
|
||||||
with:
|
|
||||||
name: manager
|
|
||||||
path: manager/app/build/outputs/apk/release/*.apk
|
|
||||||
|
|
||||||
- name: Upload mappings
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: ${{ ( github.event_name != 'pull_request' && github.ref == 'refs/heads/main' ) || github.ref_type == 'tag' }}
|
|
||||||
with:
|
|
||||||
name: "mappings"
|
|
||||||
path: "manager/app/build/outputs/mapping/release/"
|
|
||||||
|
|
||||||
- name: Bot session cache
|
|
||||||
if: github.event_name != 'pull_request' && steps.need_upload.outputs.UPLOAD == 'true'
|
|
||||||
id: bot_session_cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: scripts/ksubot.session
|
|
||||||
key: ${{ runner.os }}-bot-session
|
|
||||||
|
|
||||||
- name: Upload to telegram
|
|
||||||
if: github.event_name != 'pull_request' && steps.need_upload.outputs.UPLOAD == 'true'
|
|
||||||
env:
|
|
||||||
CHAT_ID: ${{ vars.CHAT_ID }}
|
|
||||||
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
|
|
||||||
MESSAGE_THREAD_ID: ${{ vars.MESSAGE_THREAD_ID }}
|
|
||||||
COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
|
||||||
COMMIT_URL: ${{ github.event.head_commit.url }}
|
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
||||||
TITLE: Manager
|
|
||||||
run: |
|
|
||||||
if [ ! -z "${{ secrets.BOT_TOKEN }}" ]; then
|
|
||||||
export VERSION=$(git rev-list --count HEAD)
|
|
||||||
APK=$(find ./app/build/outputs/apk/release -name "*.apk")
|
|
||||||
pip3 install telethon
|
|
||||||
python3 $GITHUB_WORKSPACE/scripts/ksubot.py $APK
|
|
||||||
fi
|
|
||||||
36
.github/workflows/build-su.yml
vendored
36
.github/workflows/build-su.yml
vendored
@@ -1,36 +0,0 @@
|
|||||||
name: Build SU
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "main", "ci" ]
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/build-su.yml'
|
|
||||||
- 'userspace/su/**'
|
|
||||||
- 'scripts/ksubot.py'
|
|
||||||
pull_request:
|
|
||||||
branches: [ "main" ]
|
|
||||||
paths:
|
|
||||||
- 'userspace/su/**'
|
|
||||||
jobs:
|
|
||||||
build-su:
|
|
||||||
name: Build userspace su
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Setup need_upload
|
|
||||||
id: need_upload
|
|
||||||
run: |
|
|
||||||
if [ ! -z "${{ secrets.BOT_TOKEN }}" ]; then
|
|
||||||
echo "UPLOAD=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "UPLOAD=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
- name: Build su
|
|
||||||
working-directory: ./userspace/su
|
|
||||||
run: $ANDROID_NDK/ndk-build
|
|
||||||
- name: Upload a Build Artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: su
|
|
||||||
path: ./userspace/su/libs
|
|
||||||
37
.github/workflows/clippy.yml
vendored
37
.github/workflows/clippy.yml
vendored
@@ -1,37 +0,0 @@
|
|||||||
name: Clippy check
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/clippy.yml'
|
|
||||||
- 'userspace/ksud/**'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/clippy.yml'
|
|
||||||
- 'userspace/ksud/**'
|
|
||||||
|
|
||||||
env:
|
|
||||||
RUSTFLAGS: '-Dwarnings'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
clippy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- run: rustup update stable
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: userspace/ksud
|
|
||||||
|
|
||||||
- name: Install cross
|
|
||||||
run: |
|
|
||||||
RUSTFLAGS="" cargo install cross --git https://github.com/cross-rs/cross --rev 66845c1
|
|
||||||
|
|
||||||
- name: Run clippy
|
|
||||||
run: |
|
|
||||||
cross clippy --manifest-path userspace/ksud/Cargo.toml --target aarch64-linux-android --release
|
|
||||||
cross clippy --manifest-path userspace/ksud/Cargo.toml --target x86_64-linux-android --release
|
|
||||||
67
.github/workflows/deploy-website.yml
vendored
67
.github/workflows/deploy-website.yml
vendored
@@ -1,67 +0,0 @@
|
|||||||
name: Deploy Website
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- website
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/deploy-website.yml'
|
|
||||||
- 'website/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
|
||||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
|
||||||
concurrency:
|
|
||||||
group: pages
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# Build job
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: ./website
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0 # Not needed if lastUpdated is not enabled
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: latest
|
|
||||||
cache: yarn # or pnpm / yarn
|
|
||||||
cache-dependency-path: website/yarn.lock
|
|
||||||
- name: Setup Pages
|
|
||||||
uses: actions/configure-pages@v5
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn install --frozen-lockfile
|
|
||||||
- name: Build with VitePress
|
|
||||||
run: |
|
|
||||||
yarn docs:build
|
|
||||||
touch docs/.vitepress/dist/.nojekyll
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-pages-artifact@v3
|
|
||||||
with:
|
|
||||||
path: website/docs/.vitepress/dist
|
|
||||||
|
|
||||||
# Deployment job
|
|
||||||
deploy:
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Deploy
|
|
||||||
steps:
|
|
||||||
- name: Deploy to GitHub Pages
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@v4
|
|
||||||
79
.github/workflows/gki-kernel-mock.yml
vendored
79
.github/workflows/gki-kernel-mock.yml
vendored
@@ -1,79 +0,0 @@
|
|||||||
name: GKI Kernel Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
description: >
|
|
||||||
Output directory of gki,
|
|
||||||
for example: android12-5.10
|
|
||||||
version_name:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
description: >
|
|
||||||
With SUBLEVEL of kernel,
|
|
||||||
for example: android12-5.10.66
|
|
||||||
tag:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
description: >
|
|
||||||
Part of branch name of common kernel manifest,
|
|
||||||
for example: android12-5.10-2021-11
|
|
||||||
os_patch_level:
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
description: >
|
|
||||||
Patch level of common kernel manifest,
|
|
||||||
for example: 2021-11
|
|
||||||
default: 2022-05
|
|
||||||
patch_path:
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
description: >
|
|
||||||
Directory name of .github/patches/<patch_path>
|
|
||||||
for example: 5.10
|
|
||||||
use_cache:
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
embed_ksud:
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
default: ksud-aarch64-linux-android
|
|
||||||
description: >
|
|
||||||
Artifact name of prebuilt ksud to be embedded
|
|
||||||
for example: ksud-aarch64-linux-android
|
|
||||||
debug:
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
build_lkm:
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
secrets:
|
|
||||||
BOOT_SIGN_KEY:
|
|
||||||
required: false
|
|
||||||
CHAT_ID:
|
|
||||||
required: false
|
|
||||||
BOT_TOKEN:
|
|
||||||
required: false
|
|
||||||
MESSAGE_THREAD_ID:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
mock_build:
|
|
||||||
name: Mock build ${{ inputs.version_name }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Create mocking ko
|
|
||||||
run: |
|
|
||||||
echo "${{ inputs.version }}_kernelsu.ko" > ${{ inputs.version }}_kernelsu.ko
|
|
||||||
- name: Upload LKM
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: ${{ inputs.build_lkm == true }}
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.version }}-lkm
|
|
||||||
path: ./*_kernelsu.ko
|
|
||||||
261
.github/workflows/gki-kernel.yml
vendored
261
.github/workflows/gki-kernel.yml
vendored
@@ -1,261 +0,0 @@
|
|||||||
name: GKI Kernel Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
description: >
|
|
||||||
Output directory of gki,
|
|
||||||
for example: android12-5.10
|
|
||||||
version_name:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
description: >
|
|
||||||
With SUBLEVEL of kernel,
|
|
||||||
for example: android12-5.10.66
|
|
||||||
tag:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
description: >
|
|
||||||
Part of branch name of common kernel manifest,
|
|
||||||
for example: android12-5.10-2021-11
|
|
||||||
os_patch_level:
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
description: >
|
|
||||||
Patch level of common kernel manifest,
|
|
||||||
for example: 2021-11
|
|
||||||
default: 2022-05
|
|
||||||
patch_path:
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
description: >
|
|
||||||
Directory name of .github/patches/<patch_path>
|
|
||||||
for example: 5.10
|
|
||||||
use_cache:
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
embed_ksud:
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
default: ksud-aarch64-linux-android
|
|
||||||
description: >
|
|
||||||
Artifact name of prebuilt ksud to be embedded
|
|
||||||
for example: ksud-aarch64-linux-android
|
|
||||||
debug:
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
build_lkm:
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
secrets:
|
|
||||||
BOOT_SIGN_KEY:
|
|
||||||
required: false
|
|
||||||
CHAT_ID:
|
|
||||||
required: false
|
|
||||||
BOT_TOKEN:
|
|
||||||
required: false
|
|
||||||
MESSAGE_THREAD_ID:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build ${{ inputs.version_name }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
CCACHE_COMPILERCHECK: "%compiler% -dumpmachine; %compiler% -dumpversion"
|
|
||||||
CCACHE_NOHASHDIR: "true"
|
|
||||||
CCACHE_HARDLINK: "true"
|
|
||||||
steps:
|
|
||||||
- name: Maximize build space
|
|
||||||
uses: easimon/maximize-build-space@master
|
|
||||||
with:
|
|
||||||
root-reserve-mb: 8192
|
|
||||||
temp-reserve-mb: 2048
|
|
||||||
remove-dotnet: 'true'
|
|
||||||
remove-android: 'true'
|
|
||||||
remove-haskell: 'true'
|
|
||||||
remove-codeql: 'true'
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
path: KernelSU
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup need_upload
|
|
||||||
id: need_upload
|
|
||||||
run: |
|
|
||||||
if [ ! -z "${{ secrets.BOT_TOKEN }}" ]; then
|
|
||||||
echo "UPLOAD=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "UPLOAD=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Setup kernel source
|
|
||||||
run: |
|
|
||||||
echo "Free space:"
|
|
||||||
df -h
|
|
||||||
cd $GITHUB_WORKSPACE
|
|
||||||
sudo apt-get install repo -y
|
|
||||||
mkdir android-kernel && cd android-kernel
|
|
||||||
repo init --depth=1 --u https://android.googlesource.com/kernel/manifest -b common-${{ inputs.tag }} --repo-rev=v2.35
|
|
||||||
REMOTE_BRANCH=$(git ls-remote https://android.googlesource.com/kernel/common ${{ inputs.tag }})
|
|
||||||
DEFAULT_MANIFEST_PATH=.repo/manifests/default.xml
|
|
||||||
if grep -q deprecated <<< $REMOTE_BRANCH; then
|
|
||||||
echo "Found deprecated branch: ${{ inputs.tag }}"
|
|
||||||
sed -i 's/"${{ inputs.tag }}"/"deprecated\/${{ inputs.tag }}"/g' $DEFAULT_MANIFEST_PATH
|
|
||||||
cat $DEFAULT_MANIFEST_PATH
|
|
||||||
fi
|
|
||||||
repo --version
|
|
||||||
repo --trace sync -c -j$(nproc --all) --no-tags
|
|
||||||
df -h
|
|
||||||
|
|
||||||
- name: Setup KernelSU
|
|
||||||
env:
|
|
||||||
PATCH_PATH: ${{ inputs.patch_path }}
|
|
||||||
IS_DEBUG_KERNEL: ${{ inputs.debug }}
|
|
||||||
run: |
|
|
||||||
cd $GITHUB_WORKSPACE/android-kernel
|
|
||||||
echo "[+] KernelSU setup"
|
|
||||||
GKI_ROOT=$(pwd)
|
|
||||||
echo "[+] GKI_ROOT: $GKI_ROOT"
|
|
||||||
echo "[+] Copy KernelSU driver to $GKI_ROOT/common/drivers"
|
|
||||||
ln -sf $GITHUB_WORKSPACE/KernelSU/kernel $GKI_ROOT/common/drivers/kernelsu
|
|
||||||
echo "[+] Add KernelSU driver to Makefile"
|
|
||||||
DRIVER_MAKEFILE=$GKI_ROOT/common/drivers/Makefile
|
|
||||||
DRIVER_KCONFIG=$GKI_ROOT/common/drivers/Kconfig
|
|
||||||
grep -q "kernelsu" "$DRIVER_MAKEFILE" || printf "\nobj-\$(CONFIG_KSU) += kernelsu/\n" >> "$DRIVER_MAKEFILE"
|
|
||||||
grep -q "kernelsu" "$DRIVER_KCONFIG" || sed -i "/endmenu/i\\source \"drivers/kernelsu/Kconfig\"" "$DRIVER_KCONFIG"
|
|
||||||
echo "[+] Apply Compilation Patches"
|
|
||||||
if [ ! -e build/build.sh ]; then
|
|
||||||
GLIBC_VERSION=$(ldd --version 2>/dev/null | head -n 1 | awk '{print $NF}')
|
|
||||||
echo "GLIBC_VERSION: $GLIBC_VERSION"
|
|
||||||
if [ "$(printf '%s\n' "2.38" "$GLIBC_VERSION" | sort -V | head -n1)" = "2.38" ]; then
|
|
||||||
echo "Patching resolve_btfids/Makefile"
|
|
||||||
cd $GKI_ROOT/common/ && sed -i '/\$(Q)\$(MAKE) -C \$(SUBCMD_SRC) OUTPUT=\$(abspath \$(dir \$@))\/ \$(abspath \$@)/s//$(Q)$(MAKE) -C $(SUBCMD_SRC) EXTRA_CFLAGS="$(CFLAGS)" OUTPUT=$(abspath $(dir $@))\/ $(abspath $@)/' tools/bpf/resolve_btfids/Makefile || echo "No patch needed."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$IS_DEBUG_KERNEL" = "true" ]; then
|
|
||||||
echo "[+] Enable debug features for kernel"
|
|
||||||
printf "\nccflags-y += -DCONFIG_KSU_DEBUG\n" >> $GITHUB_WORKSPACE/KernelSU/kernel/Makefile
|
|
||||||
fi
|
|
||||||
repo status
|
|
||||||
echo "[+] KernelSU setup done."
|
|
||||||
|
|
||||||
- name: Symbol magic
|
|
||||||
run: |
|
|
||||||
echo "[+] Export all symbol from abi_gki_aarch64.xml"
|
|
||||||
COMMON_ROOT=$GITHUB_WORKSPACE/android-kernel/common
|
|
||||||
KSU_ROOT=$GITHUB_WORKSPACE/KernelSU
|
|
||||||
ABI_XML=$COMMON_ROOT/android/abi_gki_aarch64.xml
|
|
||||||
SYMBOL_LIST=$COMMON_ROOT/android/abi_gki_aarch64
|
|
||||||
# python3 $KSU_ROOT/scripts/abi_gki_all.py $ABI_XML > $SYMBOL_LIST
|
|
||||||
echo "[+] Add KernelSU symbols"
|
|
||||||
cat $KSU_ROOT/kernel/export_symbol.txt | awk '{sub("[ \t]+","");print " "$0}' >> $SYMBOL_LIST
|
|
||||||
|
|
||||||
- name: Setup ccache
|
|
||||||
if: inputs.use_cache == true
|
|
||||||
uses: hendrikmuhs/ccache-action@v1
|
|
||||||
with:
|
|
||||||
key: gki-kernel-aarch64-${{ inputs.version_name }}
|
|
||||||
max-size: 2G
|
|
||||||
save: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
|
||||||
|
|
||||||
- name: Setup for LKM
|
|
||||||
if: ${{ inputs.build_lkm == true }}
|
|
||||||
working-directory: android-kernel
|
|
||||||
run: |
|
|
||||||
pip install ast-grep-cli
|
|
||||||
sudo apt-get install llvm-15 -y
|
|
||||||
ast-grep -U -p '$$$ check_exports($$$) {$$$}' -r '' common/scripts/mod/modpost.c
|
|
||||||
ast-grep -U -p 'check_exports($$$);' -r '' common/scripts/mod/modpost.c
|
|
||||||
sed -i '/config KSU/,/help/{s/default y/default m/}' common/drivers/kernelsu/Kconfig
|
|
||||||
echo "drivers/kernelsu/kernelsu.ko" >> common/android/gki_aarch64_modules
|
|
||||||
|
|
||||||
# bazel build, android14-5.15, android14-6.1 use bazel
|
|
||||||
if [ ! -e build/build.sh ]; then
|
|
||||||
sed -i 's/needs unknown symbol/Dont abort when unknown symbol/g' build/kernel/*.sh || echo "No unknown symbol scripts found"
|
|
||||||
if [ -e common/modules.bzl ]; then
|
|
||||||
sed -i 's/_COMMON_GKI_MODULES_LIST = \[/_COMMON_GKI_MODULES_LIST = \[ "drivers\/kernelsu\/kernelsu.ko",/g' common/modules.bzl
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
TARGET_FILE="build/kernel/build.sh"
|
|
||||||
if [ ! -e "$TARGET_FILE" ]; then
|
|
||||||
TARGET_FILE="build/build.sh"
|
|
||||||
fi
|
|
||||||
sed -i 's/needs unknown symbol/Dont abort when unknown symbol/g' $TARGET_FILE || echo "No unknown symbol in $TARGET_FILE"
|
|
||||||
sed -i 's/if ! diff -u "\${KERNEL_DIR}\/\${MODULES_ORDER}" "\${OUT_DIR}\/modules\.order"; then/if false; then/g' $TARGET_FILE
|
|
||||||
sed -i 's@${ROOT_DIR}/build/abi/compare_to_symbol_list@echo@g' $TARGET_FILE
|
|
||||||
sed -i 's/needs unknown symbol/Dont abort when unknown symbol/g' build/kernel/*.sh || echo "No unknown symbol scripts found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Make working directory clean to avoid dirty
|
|
||||||
working-directory: android-kernel
|
|
||||||
run: |
|
|
||||||
if [ -e common/BUILD.bazel ]; then
|
|
||||||
sed -i '/^[[:space:]]*"protected_exports_list"[[:space:]]*:[[:space:]]*"android\/abi_gki_protected_exports_aarch64",$/d' common/BUILD.bazel
|
|
||||||
fi
|
|
||||||
rm common/android/abi_gki_protected_exports_* || echo "No protected exports!"
|
|
||||||
git config --global user.email "bot@kernelsu.org"
|
|
||||||
git config --global user.name "KernelSUBot"
|
|
||||||
cd common/ && git add -A && git commit -a -m "Add KernelSU"
|
|
||||||
repo status
|
|
||||||
|
|
||||||
- name: Build Kernel/LKM
|
|
||||||
working-directory: android-kernel
|
|
||||||
run: |
|
|
||||||
if [ ! -z ${{ vars.EXPECTED_SIZE }} ] && [ ! -z ${{ vars.EXPECTED_HASH }} ]; then
|
|
||||||
export KSU_EXPECTED_SIZE=${{ vars.EXPECTED_SIZE }}
|
|
||||||
export KSU_EXPECTED_HASH=${{ vars.EXPECTED_HASH }}
|
|
||||||
fi
|
|
||||||
if [ -e build/build.sh ]; then
|
|
||||||
LTO=thin BUILD_CONFIG=common/build.config.gki.aarch64 build/build.sh CC="/usr/bin/ccache clang"
|
|
||||||
else
|
|
||||||
tools/bazel run --disk_cache=/home/runner/.cache/bazel --config=fast --config=stamp --lto=thin //common:kernel_aarch64_dist -- --dist_dir=dist
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Prepare artifacts
|
|
||||||
id: prepareArtifacts
|
|
||||||
run: |
|
|
||||||
OUTDIR=android-kernel/out/${{ inputs.version }}/dist
|
|
||||||
if [ ! -e $OUTDIR ]; then
|
|
||||||
OUTDIR=android-kernel/dist
|
|
||||||
fi
|
|
||||||
mkdir output
|
|
||||||
if [ "${{ inputs.build_lkm}}" = "true" ]; then
|
|
||||||
llvm-strip-15 -d $OUTDIR/kernelsu.ko
|
|
||||||
mv $OUTDIR/kernelsu.ko ./output/${{ inputs.version }}_kernelsu.ko
|
|
||||||
else
|
|
||||||
cp $OUTDIR/Image ./output/
|
|
||||||
cp $OUTDIR/Image.lz4 ./output/
|
|
||||||
git clone https://github.com/Kernel-SU/AnyKernel3
|
|
||||||
rm -rf ./AnyKernel3/.git
|
|
||||||
cp $OUTDIR/Image ./AnyKernel3/
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload Image and Image.gz
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: ${{ inputs.build_lkm == false }}
|
|
||||||
with:
|
|
||||||
name: Image-${{ inputs.version_name }}_${{ inputs.os_patch_level }}
|
|
||||||
path: ./output/*
|
|
||||||
|
|
||||||
- name: Upload AnyKernel3
|
|
||||||
if: ${{ inputs.build_lkm == false }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: AnyKernel3-${{ inputs.version_name }}_${{ inputs.os_patch_level }}
|
|
||||||
path: ./AnyKernel3/*
|
|
||||||
|
|
||||||
- name: Upload LKM
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: ${{ inputs.build_lkm == true }}
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.version }}-lkm
|
|
||||||
path: ./output/*_kernelsu.ko
|
|
||||||
40
.github/workflows/kpmmgr.yml
vendored
40
.github/workflows/kpmmgr.yml
vendored
@@ -1,40 +0,0 @@
|
|||||||
name: Build kpmmgr
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "mian" ]
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/kpmmgr.yml'
|
|
||||||
- 'userspace/kpmmgr/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
target:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
os:
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
default: self-hosted
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-susfs:
|
|
||||||
name: Build userspace kpmmgr
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Build kpmmgr
|
|
||||||
working-directory: ./userspace/kpmmgr
|
|
||||||
run: |
|
|
||||||
$ANDROID_NDK_HOME/ndk-build
|
|
||||||
|
|
||||||
- name: Upload a Build Artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: kpmmgr-aarch64-linux-android
|
|
||||||
path: ./userspace/kpmmgr/libs
|
|
||||||
74
.github/workflows/ksud.yml
vendored
74
.github/workflows/ksud.yml
vendored
@@ -1,74 +0,0 @@
|
|||||||
name: Build ksud
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
target:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
os:
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
default: ubuntu-latest
|
|
||||||
pull_lkm:
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
pack_lkm:
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
use_cache:
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ${{ inputs.os }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Pull lkms from branch
|
|
||||||
if: ${{ inputs.pack_lkm && inputs.pull_lkm }}
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: lkm
|
|
||||||
path: lkm
|
|
||||||
|
|
||||||
- name: Download lkms from artifacts
|
|
||||||
if: ${{ inputs.pack_lkm && !inputs.pull_lkm }}
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
|
|
||||||
- name: Prepare LKM files
|
|
||||||
if: ${{ inputs.pack_lkm && inputs.pull_lkm }}
|
|
||||||
run: |
|
|
||||||
cp lkm/*_kernelsu.ko ./userspace/ksud/bin/aarch64/
|
|
||||||
|
|
||||||
- name: Prepare LKM files
|
|
||||||
if: ${{ inputs.pack_lkm && !inputs.pull_lkm }}
|
|
||||||
run: |
|
|
||||||
cp android*-lkm/*_kernelsu.ko ./userspace/ksud/bin/aarch64/
|
|
||||||
|
|
||||||
- name: Setup rustup
|
|
||||||
run: |
|
|
||||||
rustup update stable
|
|
||||||
rustup target add x86_64-apple-darwin
|
|
||||||
rustup target add aarch64-apple-darwin
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: userspace/ksud
|
|
||||||
cache-targets: false
|
|
||||||
|
|
||||||
- name: Install cross
|
|
||||||
run: |
|
|
||||||
RUSTFLAGS="" cargo install cross --git https://github.com/cross-rs/cross --rev 66845c1
|
|
||||||
|
|
||||||
- name: Build ksud
|
|
||||||
run: CROSS_NO_WARNINGS=0 cross build --target ${{ inputs.target }} --release --manifest-path ./userspace/ksud/Cargo.toml
|
|
||||||
|
|
||||||
- name: Upload ksud artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ksud-${{ inputs.target }}
|
|
||||||
path: userspace/ksud/target/**/release/zakozako*
|
|
||||||
33
.github/workflows/rustfmt.yml
vendored
33
.github/workflows/rustfmt.yml
vendored
@@ -1,33 +0,0 @@
|
|||||||
name: Rustfmt check
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'main'
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/rustfmt.yml'
|
|
||||||
- 'userspace/ksud/**'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- 'main'
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/rustfmt.yml'
|
|
||||||
- 'userspace/ksud/**'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
checks: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
format:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@nightly
|
|
||||||
with:
|
|
||||||
components: rustfmt
|
|
||||||
|
|
||||||
- uses: LoliGothick/rustfmt-check@master
|
|
||||||
with:
|
|
||||||
token: ${{ github.token }}
|
|
||||||
working-directory: userspace/ksud
|
|
||||||
27
.github/workflows/shellcheck.yml
vendored
27
.github/workflows/shellcheck.yml
vendored
@@ -1,27 +0,0 @@
|
|||||||
name: ShellCheck
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'main'
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/shellcheck.yml'
|
|
||||||
- '**/*.sh'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- 'main'
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/shellcheck.yml'
|
|
||||||
- '**/*.sh'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
shellcheck:
|
|
||||||
runs-on: self-hosted
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Run ShellCheck
|
|
||||||
uses: ludeeus/action-shellcheck@2.0.0
|
|
||||||
with:
|
|
||||||
ignore_names: gradlew
|
|
||||||
ignore_paths: ./userspace/ksud/src/installer.sh
|
|
||||||
40
.github/workflows/susfs.yml
vendored
40
.github/workflows/susfs.yml
vendored
@@ -1,40 +0,0 @@
|
|||||||
name: Build susfs
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "mian" ]
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/susfs.yml'
|
|
||||||
- 'userspace/susfs/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
target:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
os:
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
default: self-hosted
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-susfs:
|
|
||||||
name: Build userspace susfs
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Build susfs
|
|
||||||
working-directory: ./userspace/susfs
|
|
||||||
run: |
|
|
||||||
$ANDROID_NDK_HOME/ndk-build
|
|
||||||
|
|
||||||
- name: Upload a Build Artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: susfs-aarch64-linux-android
|
|
||||||
path: ./userspace/susfs/libs
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
.idea
|
|
||||||
.vscode
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Reporting Security Issues
|
|
||||||
|
|
||||||
The KernelSU team and community take security bugs in KernelSU seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
|
||||||
|
|
||||||
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/tiann/KernelSU/security/advisories/new) tab, or you can mailto [weishu](mailto:twsxtd@gmail.com) directly.
|
|
||||||
|
|
||||||
The KernelSU team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
# SukiSU Ultra
|
|
||||||
|
|
||||||
**English** | [简体中文](README.md) | [日本語](README-ja.md)
|
|
||||||
|
|
||||||
Android device root solution based on [KernelSU](https://github.com/tiann/KernelSU)
|
|
||||||
|
|
||||||
**Experimental! Use at your own risk!** This solution is based on [KernelSU](https://github.com/tiann/KernelSU) and is experimental!
|
|
||||||
|
|
||||||
> This is an unofficial fork. All rights are reserved to [@tiann](https://github.com/tiann)
|
|
||||||
>
|
|
||||||
> However, we will be a separately maintained branch of KSU in the future
|
|
||||||
|
|
||||||
- Fully adapted for non-GKI devices (susfs-dev and unsusfs-patched dev branches only)
|
|
||||||
|
|
||||||
## How to add
|
|
||||||
|
|
||||||
Use the susfs-stable or susfs-dev branch (integrated susfs with support for non-GKI devices)
|
|
||||||
```
|
|
||||||
curl -LSs "https://raw.githubusercontent.com/ShirkNeko/SukiSU-Ultra/main/kernel/setup.sh" | bash -s susfs-dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the main branch
|
|
||||||
```
|
|
||||||
curl -LSs "https://raw.githubusercontent.com/ShirkNeko/KernelSU/main/kernel/setup.sh" | bash -s main
|
|
||||||
```
|
|
||||||
|
|
||||||
## How to use integrated susfs
|
|
||||||
|
|
||||||
1. Use the susfs-dev branch directly without any patching
|
|
||||||
|
|
||||||
## KPM support
|
|
||||||
|
|
||||||
- We have removed duplicate KSU functions based on KernelPatch and retained KPM support.
|
|
||||||
- We will introduce more APatch-compatible functions to ensure the integrity of KPM functionality.
|
|
||||||
|
|
||||||
Open source address: https://github.com/ShirkNeko/SukiSU_KernelPatch_patch
|
|
||||||
|
|
||||||
KPM template address: https://github.com/udochina/KPM-Build-Anywhere
|
|
||||||
|
|
||||||
## More links
|
|
||||||
|
|
||||||
Projects compiled based on Sukisu and susfs
|
|
||||||
- [GKI](https://github.com/ShirkNeko/GKI_KernelSU_SUSFS)
|
|
||||||
- [OnePlus](https://github.com/ShirkNeko/Action_OnePlus_MKSU_SUSFS)
|
|
||||||
|
|
||||||
## Hook method
|
|
||||||
- This method references the hook method from (https://github.com/rsuntk/KernelSU)
|
|
||||||
|
|
||||||
1. **KPROBES hook:**
|
|
||||||
- Also used for Loadable Kernel Module (LKM)
|
|
||||||
- Default hook method on GKI kernels.
|
|
||||||
- Need `CONFIG_KPROBES=y`
|
|
||||||
|
|
||||||
2. **Manual hook:**
|
|
||||||
- Standard KernelSU hook: https://kernelsu.org/guide/how-to-integrate-for-non-gki.html#manually-modify-the-kernel-source
|
|
||||||
- backslashxx's syscall manual hook: https://github.com/backslashxx/KernelSU/issues/5
|
|
||||||
- Default hook method on Non-GKI kernels.
|
|
||||||
- Need `CONFIG_KSU_MANUAL_HOOK=y`
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### GKI
|
|
||||||
|
|
||||||
Please follow this guide.
|
|
||||||
|
|
||||||
https://kernelsu.org/guide/installation.html
|
|
||||||
|
|
||||||
|
|
||||||
### OnePlus
|
|
||||||
|
|
||||||
1. Use the link mentioned in the 'More Links' section to create a customized build with your device information, and then flash the zip file with the AnyKernel3 suffix.
|
|
||||||
|
|
||||||
> [!Note]
|
|
||||||
> - You only need to fill in the first two parts of kernel versions, such as 5.10, 5.15, 6.1, or 6.6.
|
|
||||||
> - Please search for the processor codename by yourself, usually it is all English without numbers.
|
|
||||||
> - You can find the branch and configuration files from the OnePlus open-source kernel repository.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
1. Kernel-based `su` and root access management.
|
|
||||||
2. Not based on [OverlayFS](https://en.wikipedia.org/wiki/OverlayFS) module system, but based on [Magic Mount](https://github.com/5ec1cff/KernelSU) from 5ec1cff
|
|
||||||
3. [App Profile](https://kernelsu.org/guide/app-profile.html): Lock root privileges in a cage.
|
|
||||||
4. Bringing back non-GKI/GKI 1.0 support
|
|
||||||
5. More customization
|
|
||||||
6. Support for KPM kernel modules
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
- The file in the “kernel” directory is under [GPL-2.0-only](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html) license.
|
|
||||||
- All other parts except the “kernel” directory are under [GPL-3.0 or later](https://www.gnu.org/licenses/gpl-3.0.html) license.
|
|
||||||
|
|
||||||
## Sponsorship list
|
|
||||||
|
|
||||||
- [Ktouls](https://github.com/Ktouls) Thanks so much for bringing me support
|
|
||||||
- [zaoqi123](https://github.com/zaoqi123) It's not a bad idea to buy me a milk tea
|
|
||||||
- [wswzgdg](https://github.com/wswzgdg) Many thanks for supporting this project
|
|
||||||
- [yspbwx2010](https://github.com/yspbwx2010) Many thanks
|
|
||||||
- [DARKWWEE](https://github.com/DARKWWEE) Thanks for the 100 USDT Lao
|
|
||||||
|
|
||||||
If the above list does not have your name, I will update it as soon as possible, and thanks again for your support!
|
|
||||||
|
|
||||||
## Contributions
|
|
||||||
|
|
||||||
- [KernelSU](https://github.com/tiann/KernelSU): original project
|
|
||||||
- [MKSU](https://github.com/5ec1cff/KernelSU): Used project
|
|
||||||
- [RKSU](https://github.com/rsuntk/KernelsU): Reintroduced the support of non-GKI devices using the kernel of this project
|
|
||||||
- [susfs](https://gitlab.com/simonpunk/susfs4ksu):Used susfs file system
|
|
||||||
- [KernelSU](https://git.zx2c4.com/kernel-assisted-superuser/about/): KernelSU conceptualization
|
|
||||||
- [Magisk](https://github.com/topjohnwu/Magisk): Powerful root utility
|
|
||||||
- [genuine](https://github.com/brevent/genuine/): APK v2 Signature Verification
|
|
||||||
- [Diamorphine](https://github.com/m0nad/Diamorphine): Some rootkit utilities.
|
|
||||||
- [KernelPatch](https://github.com/bmax121/KernelPatch): KernelPatch is a key part of the APatch implementation of the kernel module
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
# SukiSU Ultra
|
|
||||||
|
|
||||||
**日本語** | [简体中文](README.md) | [English](README-en.md)
|
|
||||||
|
|
||||||
[KernelSU](https://github.com/tiann/KernelSU) をベースとした Android デバイスの root ソリューション
|
|
||||||
|
|
||||||
**試験中なビルドです!自己責任で使用してください!**<br>
|
|
||||||
このソリューションは [KernelSU](https://github.com/tiann/KernelSU) に基づいていますが、試験中なビルドです。
|
|
||||||
|
|
||||||
> これは非公式なフォークです。すべての権利は [@tiann](https://github.com/tiann) に帰属します。
|
|
||||||
>
|
|
||||||
> ただし、将来的には KSU とは別に管理されるブランチとなる予定です。
|
|
||||||
|
|
||||||
- GKI 非対応なデバイスに完全に適応 (susfs-dev と unsusfs-patched dev ブランチのみ)
|
|
||||||
|
|
||||||
## 追加方法
|
|
||||||
|
|
||||||
susfs-stable または susfs-dev ブランチ (GKI 非対応デバイスに対応する統合された susfs) 使用してください。
|
|
||||||
```
|
|
||||||
curl -LSs "https://raw.githubusercontent.com/ShirkNeko/SukiSU-Ultra/main/kernel/setup.sh" | bash -s susfs-dev
|
|
||||||
```
|
|
||||||
|
|
||||||
メインブランチを使用する場合
|
|
||||||
```
|
|
||||||
curl -LSs "https://raw.githubusercontent.com/ShirkNeko/KernelSU/main/kernel/setup.sh" | bash -s main
|
|
||||||
```
|
|
||||||
## 統合された susfs の使い方
|
|
||||||
|
|
||||||
1. パッチを当てずに susfs-dev ブランチを直接使用してください。
|
|
||||||
|
|
||||||
## KPM に対応
|
|
||||||
|
|
||||||
- KernelPatch に基づいて重複した KSU の機能を削除、KPM の対応を維持させています。
|
|
||||||
- KPM 機能の整合性を確保するために、APatch の互換機能を更に向上させる予定です。
|
|
||||||
|
|
||||||
オープンソースアドレス: https://github.com/ShirkNeko/SukiSU_KernelPatch_patch
|
|
||||||
|
|
||||||
KPM テンプレートのアドレス: https://github.com/udochina/KPM-Build-Anywhere
|
|
||||||
|
|
||||||
## その他のリンク
|
|
||||||
|
|
||||||
SukiSU と susfs をベースにコンパイルされたプロジェクトです。
|
|
||||||
|
|
||||||
- [GKI](https://github.com/ShirkNeko/GKI_KernelSU_SUSFS)
|
|
||||||
- [OnePlus](https://github.com/ShirkNeko/Action_OnePlus_MKSU_SUSFS)
|
|
||||||
|
|
||||||
## フックの方式
|
|
||||||
|
|
||||||
- この方式は (https://github.com/rsuntk/KernelSU) のフック方式を参照してください。
|
|
||||||
|
|
||||||
1. **KPROBES フック:**
|
|
||||||
- 読み込み可能なカーネルモジュールの場合 (LKM)
|
|
||||||
- GKI カーネルのデフォルトとなるフック方式
|
|
||||||
- `CONFIG_KPROBES=y` が必要です
|
|
||||||
|
|
||||||
2. **手動でフック:**
|
|
||||||
- 標準の KernelSU フック: https://kernelsu.org/guide/how-to-integrate-for-non-gki.html#manually-modify-the-kernel-source
|
|
||||||
- backslashxx syscall フック: https://github.com/backslashxx/KernelSU/issues/5
|
|
||||||
- 非 GKI カーネル用のデフォルトフッキングメソッド
|
|
||||||
- `CONFIG_KSU_MANUAL_HOOK=y` が必要です
|
|
||||||
|
|
||||||
## 使い方
|
|
||||||
|
|
||||||
### GKI
|
|
||||||
|
|
||||||
このガイドに従ってください。
|
|
||||||
|
|
||||||
https://kernelsu.org/ja_JP/guide/installation.html
|
|
||||||
|
|
||||||
### OnePlus
|
|
||||||
|
|
||||||
1. `その他のリンク`の項目に記載されているリンクを開き、デバイス情報を使用してカスタマイズされたカーネルをビルドし、AnyKernel3 の接頭辞を持つ .zip ファイルをフラッシュします。
|
|
||||||
|
|
||||||
> [!Note]
|
|
||||||
> - 5.10、5.15、6.1、6.6 などのカーネルバージョンの最初の 2 文字のみを入力する必要があります。
|
|
||||||
> - SoC のコードネームは自分で検索してください。通常は、数字がなく英語表記のみです。
|
|
||||||
> - ブランチと構成ファイルは、OnePlus オープンソースカーネルリポジトリから見つけることができます。
|
|
||||||
|
|
||||||
## 機能
|
|
||||||
|
|
||||||
1. カーネルベースな `su` および root アクセスの管理。
|
|
||||||
2. [OverlayFS](https://en.wikipedia.org/wiki/OverlayFS) モジュールシステムではなく、 5ec1cff 氏の [Magic Mount](https://github.com/5ec1cff/KernelSU) に基づいています。
|
|
||||||
3. [アプリプロファイル](https://kernelsu.org/guide/app-profile.html): root 権限をケージ内にロックします。
|
|
||||||
4. 非 GKI / GKI 1.0 の対応を復活
|
|
||||||
5. その他のカスタマイズ
|
|
||||||
6. KPM カーネルモジュールに対応
|
|
||||||
|
|
||||||
## ライセンス
|
|
||||||
|
|
||||||
- “kernel” ディレクトリ内のファイルは [GPL-2.0](https://www.gnu.org/licenses/old-licenses/gpl-2.0.ja.html) のみライセンス下にあります。
|
|
||||||
- “kernel” ディレクトリを除くその他すべての部分は [GPL-3.0 またはそれ以降](https://www.gnu.org/licenses/gpl-3.0.html) のライセンス下にあります。
|
|
||||||
|
|
||||||
## スポンサーシップの一覧
|
|
||||||
|
|
||||||
- [Ktouls](https://github.com/Ktouls) 応援をしてくれたことに感謝。
|
|
||||||
- [zaoqi123](https://github.com/zaoqi123) ミルクティーを買ってあげるのも良い考えですね。
|
|
||||||
- [wswzgdg](https://github.com/wswzgdg) このプロジェクトを支援していただき、ありがとうございます。
|
|
||||||
- [yspbwx2010](https://github.com/yspbwx2010) どうもありがとう。
|
|
||||||
- [DARKWWEE](https://github.com/DARKWWEE) ラオウ100USDTありがとう!
|
|
||||||
|
|
||||||
上記の一覧にあなたの名前がない場合は、できるだけ早急に更新しますので再度ご支援をお願いします。
|
|
||||||
|
|
||||||
## 貢献者
|
|
||||||
|
|
||||||
- [KernelSU](https://github.com/tiann/KernelSU): オリジナルのプロジェクトです。
|
|
||||||
- [MKSU](https://github.com/5ec1cff/KernelSU): 使用しているプロジェクトです。
|
|
||||||
- [RKSU](https://github.com/rsuntk/KernelsU): このプロジェクトのカーネルを使用して非 GKI デバイスのサポートを追加しています。
|
|
||||||
- [susfs](https://gitlab.com/simonpunk/susfs4ksu):使用している susfs ファイルシステムです。
|
|
||||||
- [KernelSU](https://git.zx2c4.com/kernel-assisted-superuser/about/): KernelSU について。
|
|
||||||
- [Magisk](https://github.com/topjohnwu/Magisk): パワフルな root ユーティリティです。
|
|
||||||
- [genuine](https://github.com/brevent/genuine/): APK v2 署名認証で使用しています。
|
|
||||||
- [Diamorphine](https://github.com/m0nad/Diamorphine): いくつかの rootkit ユーティリティを使用しています。
|
|
||||||
- [KernelPatch](https://github.com/bmax121/KernelPatch): KernelPatch はカーネルモジュールの APatch 実装での重要な部分となります。
|
|
||||||
116
docs/README.md
116
docs/README.md
@@ -1,116 +0,0 @@
|
|||||||
# SukiSU Ultra
|
|
||||||
|
|
||||||
**简体中文** | [English](README-en.md) | [日本語](README-ja.md)
|
|
||||||
|
|
||||||
基于 [KernelSU](https://github.com/tiann/KernelSU) 的安卓设备 root 解决方案
|
|
||||||
|
|
||||||
**实验性! 使用风险自负!**
|
|
||||||
|
|
||||||
> 这是非官方分支,保留所有权利 [@tiann](https://github.com/tiann)
|
|
||||||
>
|
|
||||||
> 但是,我们将会在未来成为一个单独维护的 KSU 分支
|
|
||||||
|
|
||||||
## 如何添加
|
|
||||||
|
|
||||||
在内核源码的根目录下执行以下命令:
|
|
||||||
|
|
||||||
使用 susfs-dev 分支(已集成 susfs,带非 GKI 设备的支持)
|
|
||||||
```
|
|
||||||
curl -LSs "https://raw.githubusercontent.com/ShirkNeko/SukiSU-Ultra/main/kernel/setup.sh" | bash -s susfs-dev
|
|
||||||
```
|
|
||||||
|
|
||||||
使用 main 分支
|
|
||||||
```
|
|
||||||
curl -LSs "https://raw.githubusercontent.com/ShirkNeko/SukiSU-Ultra/main/kernel/setup.sh" | bash -s main
|
|
||||||
```
|
|
||||||
|
|
||||||
## 如何集成 susfs
|
|
||||||
|
|
||||||
1. 直接使用 susfs-stable 或者 susfs-dev 分支,不需要再集成 susfs
|
|
||||||
|
|
||||||
## 钩子方法
|
|
||||||
|
|
||||||
- 此部分引用自 [rsuntk 的钩子方法](https://github.com/rsuntk/KernelSU)
|
|
||||||
|
|
||||||
1. **KPROBES 钩子:**
|
|
||||||
- 用于可加载内核模块 (LKM)
|
|
||||||
- GKI 2.0 内核的默认钩子方法
|
|
||||||
- 需要 `CONFIG_KPROBES=y`
|
|
||||||
|
|
||||||
2. **手动钩子:**
|
|
||||||
- 标准的 KernelSU 钩子:https://kernelsu.org/guide/how-to-integrate-for-non-gki.html#manually-modify-the-kernel-source
|
|
||||||
- backslashxx 的 syscall 手动钩子:https://github.com/backslashxx/KernelSU/issues/5
|
|
||||||
- 非 GKI 内核的默认挂钩方法
|
|
||||||
- 需要 `CONFIG_KSU_MANUAL_HOOK=y`
|
|
||||||
|
|
||||||
## KPM 支持
|
|
||||||
|
|
||||||
- 我们基于 KernelPatch 去掉了和 KSU 重复的功能,仅保留了 KPM 支持
|
|
||||||
- 我们将会引入更多的兼容 APatch 的函数来确保 KPM 功能的完整性
|
|
||||||
|
|
||||||
开源地址: https://github.com/ShirkNeko/SukiSU_KernelPatch_patch
|
|
||||||
|
|
||||||
KPM 模板地址: https://github.com/udochina/KPM-Build-Anywhere
|
|
||||||
|
|
||||||
## 更多链接
|
|
||||||
|
|
||||||
基于 SukiSU 和 susfs 编译的项目
|
|
||||||
- [GKI](https://github.com/ShirkNeko/GKI_KernelSU_SUSFS)
|
|
||||||
- [一加](https://github.com/ShirkNeko/Action_OnePlus_MKSU_SUSFS)
|
|
||||||
|
|
||||||
## 使用方法
|
|
||||||
|
|
||||||
### 普适的 GKI
|
|
||||||
|
|
||||||
请**全部**参考 https://kernelsu.org/zh_CN/guide/installation.html
|
|
||||||
|
|
||||||
> [!Note]
|
|
||||||
> 1. 适用于如小米、红米、三星等的 GKI 2.0 的设备 (不包含魔改内核的厂商如魅族、一加、真我和 oppo)
|
|
||||||
> 2. 找到[更多链接](#%E6%9B%B4%E5%A4%9A%E9%93%BE%E6%8E%A5)里的 GKI 构建的项目。找到设备内核版本。然后下载下来,用TWRP或者内核刷写工具刷入带 AnyKernel3 后缀的压缩包即可
|
|
||||||
> 3. 一般不带后缀的 .zip 压缩包是未压缩的,gz 后缀的为天玑机型所使用的压缩方式
|
|
||||||
|
|
||||||
|
|
||||||
### 一加
|
|
||||||
|
|
||||||
1.找到更多链接里的一加项目进行自行填写,然后云编译构建,最后刷入带 AnyKernel3 后缀的压缩包即可
|
|
||||||
|
|
||||||
> [!Note]
|
|
||||||
> - 内核版本只需要填写前两位即可,如 5.10,5.15,6.1,6.6
|
|
||||||
> - 处理器代号请自行搜索,一般为全英文不带数字的代号
|
|
||||||
> - 分支和配置文件请自行到一加内核开源地址进行填写
|
|
||||||
|
|
||||||
## 特点
|
|
||||||
|
|
||||||
1. 基于内核的 `su` 和 root 访问管理
|
|
||||||
2. 基于 5ec1cff 的 [Magic Mount](https://github.com/5ec1cff/KernelSU) 的模块系统
|
|
||||||
3. [App Profile](https://kernelsu.org/guide/app-profile.html):将 root 权限锁在笼子里
|
|
||||||
4. 恢复对非 GKI 2.0 内核的支持
|
|
||||||
5. 更多自定义功能
|
|
||||||
6. 对 KPM 内核模块的支持
|
|
||||||
|
|
||||||
## 许可证
|
|
||||||
|
|
||||||
- `kernel` 目录下的文件是 [GPL-2.0-only](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html)。
|
|
||||||
- 除 `kernel` 目录外,所有其他部分均为 [GPL-3.0 或更高版本](https://www.gnu.org/licenses/gpl-3.0.html)。
|
|
||||||
|
|
||||||
## 赞助名单
|
|
||||||
|
|
||||||
- [Ktouls](https://github.com/Ktouls) 非常感谢你给我带来的支持
|
|
||||||
- [zaoqi123](https://github.com/zaoqi123) 请我喝奶茶也不错
|
|
||||||
- [wswzgdg](https://github.com/wswzgdg) 非常感谢对此项目的支持
|
|
||||||
- [yspbwx2010](https://github.com/yspbwx2010) 非常感谢
|
|
||||||
- [DARKWWEE](https://github.com/DARKWWEE) 感谢老哥的 100 USDT
|
|
||||||
|
|
||||||
如果以上名单没有你的名称,我会及时更新,再次感谢大家的支持
|
|
||||||
|
|
||||||
## 贡献
|
|
||||||
|
|
||||||
- [KernelSU](https://github.com/tiann/KernelSU):原始项目
|
|
||||||
- [MKSU](https://github.com/5ec1cff/KernelSU):使用的项目
|
|
||||||
- [RKSU](https://github.com/rsuntk/KernelsU):使用该项目的 kernel 对非GKI设备重新进行支持
|
|
||||||
- [susfs4ksu](https://gitlab.com/simonpunk/susfs4ksu):使用的 susfs 文件系统
|
|
||||||
- [kernel-assisted-superuser](https://git.zx2c4.com/kernel-assisted-superuser/about/):KernelSU 的构想
|
|
||||||
- [Magisk](https://github.com/topjohnwu/Magisk):强大的 root 工具
|
|
||||||
- [genuine](https://github.com/brevent/genuine/):APK v2 签名验证
|
|
||||||
- [Diamorphine](https://github.com/m0nad/Diamorphine):一些 rootkit 技能
|
|
||||||
- [KernelPatch](https://github.com/bmax121/KernelPatch): KernelPatch 是 APatch 实现内核模块的关键部分
|
|
||||||
121
js/README.md
121
js/README.md
@@ -1,121 +0,0 @@
|
|||||||
# Library for KernelSU's module WebUI
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```sh
|
|
||||||
yarn add kernelsu
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### exec
|
|
||||||
|
|
||||||
Spawns a **root** shell and runs a command within that shell, returning a Promise that resolves with the `stdout` and `stderr` outputs upon completion.
|
|
||||||
|
|
||||||
- `command` `<string>` The command to run, with space-separated arguments.
|
|
||||||
- `options` `<Object>`
|
|
||||||
- `cwd` - Current working directory of the child process.
|
|
||||||
- `env` - Environment key-value pairs.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { exec } from 'kernelsu';
|
|
||||||
|
|
||||||
const { errno, stdout, stderr } = await exec('ls -l', { cwd: '/tmp' });
|
|
||||||
if (errno === 0) {
|
|
||||||
// success
|
|
||||||
console.log(stdout);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### spawn
|
|
||||||
|
|
||||||
Spawns a new process using the given `command` in **root** shell, with command-line arguments in `args`. If omitted, `args` defaults to an empty array.
|
|
||||||
|
|
||||||
Returns a `ChildProcess` instance. Instances of `ChildProcess` represent spawned child processes.
|
|
||||||
|
|
||||||
- `command` `<string>` The command to run.
|
|
||||||
- `args` `<string[]>` List of string arguments.
|
|
||||||
- `options` `<Object>`:
|
|
||||||
- `cwd` `<string>` - Current working directory of the child process.
|
|
||||||
- `env` `<Object>` - Environment key-value pairs.
|
|
||||||
|
|
||||||
Example of running `ls -lh /data`, capturing `stdout`, `stderr`, and the exit code:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { spawn } from 'kernelsu';
|
|
||||||
|
|
||||||
const ls = spawn('ls', ['-lh', '/data']);
|
|
||||||
|
|
||||||
ls.stdout.on('data', (data) => {
|
|
||||||
console.log(`stdout: ${data}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
ls.stderr.on('data', (data) => {
|
|
||||||
console.log(`stderr: ${data}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
ls.on('exit', (code) => {
|
|
||||||
console.log(`child process exited with code ${code}`);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### ChildProcess
|
|
||||||
|
|
||||||
##### Event 'exit'
|
|
||||||
|
|
||||||
- `code` `<number>` The exit code if the child process exited on its own.
|
|
||||||
|
|
||||||
The `'exit'` event is emitted when the child process ends. If the process exits, `code` contains the final exit code; otherwise, it is null.
|
|
||||||
|
|
||||||
##### Event 'error'
|
|
||||||
|
|
||||||
- `err` `<Error>` The error.
|
|
||||||
|
|
||||||
The `'error'` event is emitted whenever:
|
|
||||||
|
|
||||||
- The process could not be spawned.
|
|
||||||
- The process could not be killed.
|
|
||||||
|
|
||||||
##### `stdout`
|
|
||||||
|
|
||||||
A `Readable Stream` that represents the child process's `stdout`.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const subprocess = spawn('ls');
|
|
||||||
|
|
||||||
subprocess.stdout.on('data', (data) => {
|
|
||||||
console.log(`Received chunk ${data}`);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `stderr`
|
|
||||||
|
|
||||||
A `Readable Stream` that represents the child process's `stderr`.
|
|
||||||
|
|
||||||
### fullScreen
|
|
||||||
|
|
||||||
Request the WebView enter/exit full screen.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { fullScreen } from 'kernelsu';
|
|
||||||
fullScreen(true);
|
|
||||||
```
|
|
||||||
|
|
||||||
### toast
|
|
||||||
|
|
||||||
Show a toast message.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { toast } from 'kernelsu';
|
|
||||||
toast('Hello, world!');
|
|
||||||
```
|
|
||||||
|
|
||||||
### moduleInfo
|
|
||||||
|
|
||||||
Get module info.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { moduleInfo } from 'kernelsu';
|
|
||||||
// print moduleId in console
|
|
||||||
console.log(moduleInfo());
|
|
||||||
```
|
|
||||||
48
js/index.d.ts
vendored
48
js/index.d.ts
vendored
@@ -1,48 +0,0 @@
|
|||||||
interface ExecOptions {
|
|
||||||
cwd?: string,
|
|
||||||
env?: { [key: string]: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExecResults {
|
|
||||||
errno: number,
|
|
||||||
stdout: string,
|
|
||||||
stderr: string
|
|
||||||
}
|
|
||||||
|
|
||||||
declare function exec(command: string): Promise<ExecResults>;
|
|
||||||
declare function exec(command: string, options: ExecOptions): Promise<ExecResults>;
|
|
||||||
|
|
||||||
interface SpawnOptions {
|
|
||||||
cwd?: string,
|
|
||||||
env?: { [key: string]: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Stdio {
|
|
||||||
on(event: 'data', callback: (data: string) => void)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChildProcess {
|
|
||||||
stdout: Stdio,
|
|
||||||
stderr: Stdio,
|
|
||||||
on(event: 'exit', callback: (code: number) => void)
|
|
||||||
on(event: 'error', callback: (err: any) => void)
|
|
||||||
}
|
|
||||||
|
|
||||||
declare function spawn(command: string): ChildProcess;
|
|
||||||
declare function spawn(command: string, args: string[]): ChildProcess;
|
|
||||||
declare function spawn(command: string, options: SpawnOptions): ChildProcess;
|
|
||||||
declare function spawn(command: string, args: string[], options: SpawnOptions): ChildProcess;
|
|
||||||
|
|
||||||
declare function fullScreen(isFullScreen: boolean);
|
|
||||||
|
|
||||||
declare function toast(message: string);
|
|
||||||
|
|
||||||
declare function moduleInfo(): string;
|
|
||||||
|
|
||||||
export {
|
|
||||||
exec,
|
|
||||||
spawn,
|
|
||||||
fullScreen,
|
|
||||||
toast,
|
|
||||||
moduleInfo
|
|
||||||
}
|
|
||||||
119
js/index.js
119
js/index.js
@@ -1,119 +0,0 @@
|
|||||||
let callbackCounter = 0;
|
|
||||||
function getUniqueCallbackName(prefix) {
|
|
||||||
return `${prefix}_callback_${Date.now()}_${callbackCounter++}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function exec(command, options) {
|
|
||||||
if (typeof options === "undefined") {
|
|
||||||
options = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// Generate a unique callback function name
|
|
||||||
const callbackFuncName = getUniqueCallbackName("exec");
|
|
||||||
|
|
||||||
// Define the success callback function
|
|
||||||
window[callbackFuncName] = (errno, stdout, stderr) => {
|
|
||||||
resolve({ errno, stdout, stderr });
|
|
||||||
cleanup(callbackFuncName);
|
|
||||||
};
|
|
||||||
|
|
||||||
function cleanup(successName) {
|
|
||||||
delete window[successName];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
ksu.exec(command, JSON.stringify(options), callbackFuncName);
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
cleanup(callbackFuncName);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stdio() {
|
|
||||||
this.listeners = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
Stdio.prototype.on = function (event, listener) {
|
|
||||||
if (!this.listeners[event]) {
|
|
||||||
this.listeners[event] = [];
|
|
||||||
}
|
|
||||||
this.listeners[event].push(listener);
|
|
||||||
};
|
|
||||||
|
|
||||||
Stdio.prototype.emit = function (event, ...args) {
|
|
||||||
if (this.listeners[event]) {
|
|
||||||
this.listeners[event].forEach((listener) => listener(...args));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function ChildProcess() {
|
|
||||||
this.listeners = {};
|
|
||||||
this.stdin = new Stdio();
|
|
||||||
this.stdout = new Stdio();
|
|
||||||
this.stderr = new Stdio();
|
|
||||||
}
|
|
||||||
|
|
||||||
ChildProcess.prototype.on = function (event, listener) {
|
|
||||||
if (!this.listeners[event]) {
|
|
||||||
this.listeners[event] = [];
|
|
||||||
}
|
|
||||||
this.listeners[event].push(listener);
|
|
||||||
};
|
|
||||||
|
|
||||||
ChildProcess.prototype.emit = function (event, ...args) {
|
|
||||||
if (this.listeners[event]) {
|
|
||||||
this.listeners[event].forEach((listener) => listener(...args));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export function spawn(command, args, options) {
|
|
||||||
if (typeof args === "undefined") {
|
|
||||||
args = [];
|
|
||||||
} else if (!(args instanceof Array)) {
|
|
||||||
// allow for (command, options) signature
|
|
||||||
options = args;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof options === "undefined") {
|
|
||||||
options = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const child = new ChildProcess();
|
|
||||||
const childCallbackName = getUniqueCallbackName("spawn");
|
|
||||||
window[childCallbackName] = child;
|
|
||||||
|
|
||||||
function cleanup(name) {
|
|
||||||
delete window[name];
|
|
||||||
}
|
|
||||||
|
|
||||||
child.on("exit", code => {
|
|
||||||
cleanup(childCallbackName);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
ksu.spawn(
|
|
||||||
command,
|
|
||||||
JSON.stringify(args),
|
|
||||||
JSON.stringify(options),
|
|
||||||
childCallbackName
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
child.emit("error", error);
|
|
||||||
cleanup(childCallbackName);
|
|
||||||
}
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fullScreen(isFullScreen) {
|
|
||||||
ksu.fullScreen(isFullScreen);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toast(message) {
|
|
||||||
ksu.toast(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moduleInfo() {
|
|
||||||
return ksu.moduleInfo();
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "kernelsu",
|
|
||||||
"version": "1.0.7",
|
|
||||||
"description": "Library for KernelSU's module WebUI",
|
|
||||||
"main": "index.js",
|
|
||||||
"types": "index.d.ts",
|
|
||||||
"scripts": {
|
|
||||||
"test": "npm run test"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git+https://github.com/tiann/KernelSU.git"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"su",
|
|
||||||
"kernelsu",
|
|
||||||
"module",
|
|
||||||
"webui"
|
|
||||||
],
|
|
||||||
"author": "weishu",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/tiann/KernelSU/issues"
|
|
||||||
},
|
|
||||||
"homepage": "https://github.com/tiann/KernelSU#readme"
|
|
||||||
}
|
|
||||||
14
justfile
14
justfile
@@ -1,14 +0,0 @@
|
|||||||
alias bk := build_ksud
|
|
||||||
alias bm := build_manager
|
|
||||||
|
|
||||||
build_ksud:
|
|
||||||
cross build --target aarch64-linux-android --release --manifest-path ./userspace/ksud/Cargo.toml
|
|
||||||
|
|
||||||
build_manager: build_ksud
|
|
||||||
cp userspace/ksud/target/aarch64-linux-android/release/ksud manager/app/src/main/jniLibs/arm64-v8a/libksud.so
|
|
||||||
cd manager && ./gradlew aDebug
|
|
||||||
|
|
||||||
clippy:
|
|
||||||
cargo fmt --manifest-path ./userspace/ksud/Cargo.toml
|
|
||||||
cross clippy --target x86_64-pc-windows-gnu --release --manifest-path ./userspace/ksud/Cargo.toml
|
|
||||||
cross clippy --target aarch64-linux-android --release --manifest-path ./userspace/ksud/Cargo.toml
|
|
||||||
10
manager/.gitignore
vendored
10
manager/.gitignore
vendored
@@ -1,10 +0,0 @@
|
|||||||
*.iml
|
|
||||||
.gradle
|
|
||||||
.idea
|
|
||||||
.kotlin
|
|
||||||
.DS_Store
|
|
||||||
build
|
|
||||||
captures
|
|
||||||
.cxx
|
|
||||||
local.properties
|
|
||||||
key.jks
|
|
||||||
2
manager/app/.gitignore
vendored
2
manager/app/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
/build
|
|
||||||
/release/
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
@file:Suppress("UnstableApiUsage")
|
|
||||||
|
|
||||||
import com.android.build.api.dsl.ApkSigningConfig
|
|
||||||
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
|
||||||
import com.android.build.gradle.tasks.PackageAndroidArtifact
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
alias(libs.plugins.agp.app)
|
|
||||||
alias(libs.plugins.kotlin)
|
|
||||||
alias(libs.plugins.compose.compiler)
|
|
||||||
alias(libs.plugins.ksp)
|
|
||||||
alias(libs.plugins.lsplugin.apksign)
|
|
||||||
id("kotlin-parcelize")
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
val managerVersionCode: Int by rootProject.extra
|
|
||||||
val managerVersionName: String by rootProject.extra
|
|
||||||
|
|
||||||
apksign {
|
|
||||||
storeFileProperty = "KEYSTORE_FILE"
|
|
||||||
storePasswordProperty = "KEYSTORE_PASSWORD"
|
|
||||||
keyAliasProperty = "KEY_ALIAS"
|
|
||||||
keyPasswordProperty = "KEY_PASSWORD"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
android {
|
|
||||||
|
|
||||||
/**signingConfigs {
|
|
||||||
create("Debug") {
|
|
||||||
storeFile = file("D:\\other\\AndroidTool\\android_key\\keystore\\release-key.keystore")
|
|
||||||
storePassword = ""
|
|
||||||
keyAlias = ""
|
|
||||||
keyPassword = ""
|
|
||||||
}
|
|
||||||
}**/
|
|
||||||
namespace = "com.sukisu.ultra"
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
isMinifyEnabled = true
|
|
||||||
isShrinkResources = true
|
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
|
||||||
}
|
|
||||||
/**debug {
|
|
||||||
signingConfig = signingConfigs.named("Debug").get() as ApkSigningConfig
|
|
||||||
}**/
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
aidl = true
|
|
||||||
buildConfig = true
|
|
||||||
compose = true
|
|
||||||
prefab = true
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "21"
|
|
||||||
}
|
|
||||||
|
|
||||||
packaging {
|
|
||||||
jniLibs {
|
|
||||||
useLegacyPackaging = true
|
|
||||||
}
|
|
||||||
resources {
|
|
||||||
// https://stackoverflow.com/a/58956288
|
|
||||||
// It will break Layout Inspector, but it's unused for release build.
|
|
||||||
excludes += "META-INF/*.version"
|
|
||||||
// https://github.com/Kotlin/kotlinx.coroutines?tab=readme-ov-file#avoiding-including-the-debug-infrastructure-in-the-resulting-apk
|
|
||||||
excludes += "DebugProbesKt.bin"
|
|
||||||
// https://issueantenna.com/repo/kotlin/kotlinx.coroutines/issues/3158
|
|
||||||
excludes += "kotlin-tooling-metadata.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
externalNativeBuild {
|
|
||||||
cmake {
|
|
||||||
path("src/main/cpp/CMakeLists.txt")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applicationVariants.all {
|
|
||||||
outputs.forEach {
|
|
||||||
val output = it as BaseVariantOutputImpl
|
|
||||||
output.outputFileName = "SukiSU_${managerVersionName}_${managerVersionCode}-$name.apk"
|
|
||||||
}
|
|
||||||
kotlin.sourceSets {
|
|
||||||
getByName(name) {
|
|
||||||
kotlin.srcDir("build/generated/ksp/$name/kotlin")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://stackoverflow.com/a/77745844
|
|
||||||
tasks.withType<PackageAndroidArtifact> {
|
|
||||||
doFirst { appMetadata.asFile.orNull?.writeText("") }
|
|
||||||
}
|
|
||||||
|
|
||||||
dependenciesInfo {
|
|
||||||
includeInApk = false
|
|
||||||
includeInBundle = false
|
|
||||||
}
|
|
||||||
|
|
||||||
androidResources {
|
|
||||||
generateLocaleConfig = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(libs.androidx.activity.compose)
|
|
||||||
implementation(libs.androidx.navigation.compose)
|
|
||||||
|
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
|
||||||
implementation(libs.androidx.compose.material.icons.extended)
|
|
||||||
implementation(libs.androidx.compose.material)
|
|
||||||
implementation(libs.androidx.compose.material3)
|
|
||||||
implementation(libs.androidx.compose.ui)
|
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
|
||||||
implementation(libs.androidx.foundation)
|
|
||||||
implementation(libs.androidx.documentfile)
|
|
||||||
|
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
|
||||||
|
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
|
||||||
|
|
||||||
implementation(libs.compose.destinations.core)
|
|
||||||
ksp(libs.compose.destinations.ksp)
|
|
||||||
|
|
||||||
implementation(libs.com.github.topjohnwu.libsu.core)
|
|
||||||
implementation(libs.com.github.topjohnwu.libsu.service)
|
|
||||||
implementation(libs.com.github.topjohnwu.libsu.io)
|
|
||||||
|
|
||||||
implementation(libs.dev.rikka.rikkax.parcelablelist)
|
|
||||||
|
|
||||||
implementation(libs.io.coil.kt.coil.compose)
|
|
||||||
|
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
|
||||||
|
|
||||||
implementation(libs.me.zhanghai.android.appiconloader.coil)
|
|
||||||
|
|
||||||
implementation(libs.sheet.compose.dialogs.core)
|
|
||||||
implementation(libs.sheet.compose.dialogs.list)
|
|
||||||
implementation(libs.sheet.compose.dialogs.input)
|
|
||||||
|
|
||||||
implementation(libs.markdown)
|
|
||||||
implementation(libs.androidx.webkit)
|
|
||||||
|
|
||||||
implementation(libs.lsposed.cxx)
|
|
||||||
|
|
||||||
implementation(libs.com.github.topjohnwu.libsu.core)
|
|
||||||
|
|
||||||
}
|
|
||||||
0
manager/app/proguard-rules.pro
vendored
0
manager/app/proguard-rules.pro
vendored
@@ -1,53 +0,0 @@
|
|||||||
<?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.READ_EXTERNAL_STORAGE"
|
|
||||||
tools:ignore="ScopedStorage" />
|
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
|
||||||
tools:ignore="ScopedStorage" />
|
|
||||||
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:name=".KernelSUApplication"
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
|
||||||
android:enableOnBackInvokedCallback="true"
|
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@style/Theme.KernelSU"
|
|
||||||
android:requestLegacyExternalStorage="true"
|
|
||||||
tools:targetApi="34">
|
|
||||||
<activity
|
|
||||||
android:name=".ui.MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:theme="@style/Theme.KernelSU">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".ui.webui.WebUIActivity"
|
|
||||||
android:autoRemoveFromRecents="true"
|
|
||||||
android:documentLaunchMode="intoExisting"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/Theme.KernelSU.WebUI" />
|
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.fileprovider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/filepaths" />
|
|
||||||
</provider>
|
|
||||||
</application>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package com.sukisu.zako;
|
|
||||||
|
|
||||||
import android.content.pm.PackageInfo;
|
|
||||||
import rikka.parcelablelist.ParcelableListSlice;
|
|
||||||
|
|
||||||
interface IKsuInterface {
|
|
||||||
ParcelableListSlice<PackageInfo> getPackages(int flags);
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,21 +0,0 @@
|
|||||||
|
|
||||||
# For more information about using CMake with Android Studio, read the
|
|
||||||
# documentation: https://d.android.com/studio/projects/add-native-code.html
|
|
||||||
|
|
||||||
# Sets the minimum version of CMake required to build the native library.
|
|
||||||
cmake_minimum_required(VERSION 3.18.1)
|
|
||||||
|
|
||||||
project("kernelsu")
|
|
||||||
|
|
||||||
find_package(cxx REQUIRED CONFIG)
|
|
||||||
link_libraries(cxx::cxx)
|
|
||||||
|
|
||||||
add_library(zako
|
|
||||||
SHARED
|
|
||||||
jni.cc
|
|
||||||
ksu.cc
|
|
||||||
)
|
|
||||||
|
|
||||||
find_library(log-lib log)
|
|
||||||
|
|
||||||
target_link_libraries(zako ${log-lib})
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
#include <jni.h>
|
|
||||||
|
|
||||||
#include <sys/prctl.h>
|
|
||||||
|
|
||||||
#include <android/log.h>
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
#include "ksu.h"
|
|
||||||
|
|
||||||
#define LOG_TAG "KernelSU"
|
|
||||||
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
|
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jboolean JNICALL
|
|
||||||
Java_com_sukisu_ultra_Natives_becomeManager(JNIEnv *env, jobject, jstring pkg) {
|
|
||||||
auto cpkg = env->GetStringUTFChars(pkg, nullptr);
|
|
||||||
auto result = become_manager(cpkg);
|
|
||||||
env->ReleaseStringUTFChars(pkg, cpkg);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jint JNICALL
|
|
||||||
Java_com_sukisu_ultra_Natives_getVersion(JNIEnv *env, jobject) {
|
|
||||||
return get_version();
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jintArray JNICALL
|
|
||||||
Java_com_sukisu_ultra_Natives_getAllowList(JNIEnv *env, jobject) {
|
|
||||||
int uids[1024];
|
|
||||||
int size = 0;
|
|
||||||
bool result = get_allow_list(uids, &size);
|
|
||||||
LOGD("getAllowList: %d, size: %d", result, size);
|
|
||||||
if (result) {
|
|
||||||
auto array = env->NewIntArray(size);
|
|
||||||
env->SetIntArrayRegion(array, 0, size, uids);
|
|
||||||
return array;
|
|
||||||
}
|
|
||||||
return env->NewIntArray(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jboolean JNICALL
|
|
||||||
Java_com_sukisu_ultra_Natives_isSafeMode(JNIEnv *env, jclass clazz) {
|
|
||||||
return is_safe_mode();
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jboolean JNICALL
|
|
||||||
Java_com_sukisu_ultra_Natives_isLkmMode(JNIEnv *env, jclass clazz) {
|
|
||||||
return is_lkm_mode();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void fillIntArray(JNIEnv *env, jobject list, int *data, int count) {
|
|
||||||
auto cls = env->GetObjectClass(list);
|
|
||||||
auto add = env->GetMethodID(cls, "add", "(Ljava/lang/Object;)Z");
|
|
||||||
auto integerCls = env->FindClass("java/lang/Integer");
|
|
||||||
auto constructor = env->GetMethodID(integerCls, "<init>", "(I)V");
|
|
||||||
for (int i = 0; i < count; ++i) {
|
|
||||||
auto integer = env->NewObject(integerCls, constructor, data[i]);
|
|
||||||
env->CallBooleanMethod(list, add, integer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void addIntToList(JNIEnv *env, jobject list, int ele) {
|
|
||||||
auto cls = env->GetObjectClass(list);
|
|
||||||
auto add = env->GetMethodID(cls, "add", "(Ljava/lang/Object;)Z");
|
|
||||||
auto integerCls = env->FindClass("java/lang/Integer");
|
|
||||||
auto constructor = env->GetMethodID(integerCls, "<init>", "(I)V");
|
|
||||||
auto integer = env->NewObject(integerCls, constructor, ele);
|
|
||||||
env->CallBooleanMethod(list, add, integer);
|
|
||||||
}
|
|
||||||
|
|
||||||
static uint64_t capListToBits(JNIEnv *env, jobject list) {
|
|
||||||
auto cls = env->GetObjectClass(list);
|
|
||||||
auto get = env->GetMethodID(cls, "get", "(I)Ljava/lang/Object;");
|
|
||||||
auto size = env->GetMethodID(cls, "size", "()I");
|
|
||||||
auto listSize = env->CallIntMethod(list, size);
|
|
||||||
auto integerCls = env->FindClass("java/lang/Integer");
|
|
||||||
auto intValue = env->GetMethodID(integerCls, "intValue", "()I");
|
|
||||||
uint64_t result = 0;
|
|
||||||
for (int i = 0; i < listSize; ++i) {
|
|
||||||
auto integer = env->CallObjectMethod(list, get, i);
|
|
||||||
int data = env->CallIntMethod(integer, intValue);
|
|
||||||
|
|
||||||
if (cap_valid(data)) {
|
|
||||||
result |= (1ULL << data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
static int getListSize(JNIEnv *env, jobject list) {
|
|
||||||
auto cls = env->GetObjectClass(list);
|
|
||||||
auto size = env->GetMethodID(cls, "size", "()I");
|
|
||||||
return env->CallIntMethod(list, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void fillArrayWithList(JNIEnv *env, jobject list, int *data, int count) {
|
|
||||||
auto cls = env->GetObjectClass(list);
|
|
||||||
auto get = env->GetMethodID(cls, "get", "(I)Ljava/lang/Object;");
|
|
||||||
auto integerCls = env->FindClass("java/lang/Integer");
|
|
||||||
auto intValue = env->GetMethodID(integerCls, "intValue", "()I");
|
|
||||||
for (int i = 0; i < count; ++i) {
|
|
||||||
auto integer = env->CallObjectMethod(list, get, i);
|
|
||||||
data[i] = env->CallIntMethod(integer, intValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jobject JNICALL
|
|
||||||
Java_com_sukisu_ultra_Natives_getAppProfile(JNIEnv *env, jobject, jstring pkg, jint uid) {
|
|
||||||
if (env->GetStringLength(pkg) > KSU_MAX_PACKAGE_NAME) {
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
p_key_t key = {};
|
|
||||||
auto cpkg = env->GetStringUTFChars(pkg, nullptr);
|
|
||||||
strcpy(key, cpkg);
|
|
||||||
env->ReleaseStringUTFChars(pkg, cpkg);
|
|
||||||
|
|
||||||
app_profile profile = {};
|
|
||||||
profile.version = KSU_APP_PROFILE_VER;
|
|
||||||
|
|
||||||
strcpy(profile.key, key);
|
|
||||||
profile.current_uid = uid;
|
|
||||||
|
|
||||||
bool useDefaultProfile = !get_app_profile(key, &profile);
|
|
||||||
|
|
||||||
auto cls = env->FindClass("com/sukisu/ultra/Natives$Profile");
|
|
||||||
auto constructor = env->GetMethodID(cls, "<init>", "()V");
|
|
||||||
auto obj = env->NewObject(cls, constructor);
|
|
||||||
auto keyField = env->GetFieldID(cls, "name", "Ljava/lang/String;");
|
|
||||||
auto currentUidField = env->GetFieldID(cls, "currentUid", "I");
|
|
||||||
auto allowSuField = env->GetFieldID(cls, "allowSu", "Z");
|
|
||||||
|
|
||||||
auto rootUseDefaultField = env->GetFieldID(cls, "rootUseDefault", "Z");
|
|
||||||
auto rootTemplateField = env->GetFieldID(cls, "rootTemplate", "Ljava/lang/String;");
|
|
||||||
|
|
||||||
auto uidField = env->GetFieldID(cls, "uid", "I");
|
|
||||||
auto gidField = env->GetFieldID(cls, "gid", "I");
|
|
||||||
auto groupsField = env->GetFieldID(cls, "groups", "Ljava/util/List;");
|
|
||||||
auto capabilitiesField = env->GetFieldID(cls, "capabilities", "Ljava/util/List;");
|
|
||||||
auto domainField = env->GetFieldID(cls, "context", "Ljava/lang/String;");
|
|
||||||
auto namespacesField = env->GetFieldID(cls, "namespace", "I");
|
|
||||||
|
|
||||||
auto nonRootUseDefaultField = env->GetFieldID(cls, "nonRootUseDefault", "Z");
|
|
||||||
auto umountModulesField = env->GetFieldID(cls, "umountModules", "Z");
|
|
||||||
|
|
||||||
env->SetObjectField(obj, keyField, env->NewStringUTF(profile.key));
|
|
||||||
env->SetIntField(obj, currentUidField, profile.current_uid);
|
|
||||||
|
|
||||||
if (useDefaultProfile) {
|
|
||||||
// no profile found, so just use default profile:
|
|
||||||
// don't allow root and use default profile!
|
|
||||||
LOGD("use default profile for: %s, %d", key, uid);
|
|
||||||
|
|
||||||
// allow_su = false
|
|
||||||
// non root use default = true
|
|
||||||
env->SetBooleanField(obj, allowSuField, false);
|
|
||||||
env->SetBooleanField(obj, nonRootUseDefaultField, true);
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto allowSu = profile.allow_su;
|
|
||||||
|
|
||||||
if (allowSu) {
|
|
||||||
env->SetBooleanField(obj, rootUseDefaultField, (jboolean) profile.rp_config.use_default);
|
|
||||||
if (strlen(profile.rp_config.template_name) > 0) {
|
|
||||||
env->SetObjectField(obj, rootTemplateField,
|
|
||||||
env->NewStringUTF(profile.rp_config.template_name));
|
|
||||||
}
|
|
||||||
|
|
||||||
env->SetIntField(obj, uidField, profile.rp_config.profile.uid);
|
|
||||||
env->SetIntField(obj, gidField, profile.rp_config.profile.gid);
|
|
||||||
|
|
||||||
jobject groupList = env->GetObjectField(obj, groupsField);
|
|
||||||
int groupCount = profile.rp_config.profile.groups_count;
|
|
||||||
if (groupCount > KSU_MAX_GROUPS) {
|
|
||||||
LOGD("kernel group count too large: %d???", groupCount);
|
|
||||||
groupCount = KSU_MAX_GROUPS;
|
|
||||||
}
|
|
||||||
fillIntArray(env, groupList, profile.rp_config.profile.groups, groupCount);
|
|
||||||
|
|
||||||
jobject capList = env->GetObjectField(obj, capabilitiesField);
|
|
||||||
for (int i = 0; i <= CAP_LAST_CAP; i++) {
|
|
||||||
if (profile.rp_config.profile.capabilities.effective & (1ULL << i)) {
|
|
||||||
addIntToList(env, capList, i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
env->SetObjectField(obj, domainField,
|
|
||||||
env->NewStringUTF(profile.rp_config.profile.selinux_domain));
|
|
||||||
env->SetIntField(obj, namespacesField, profile.rp_config.profile.namespaces);
|
|
||||||
env->SetBooleanField(obj, allowSuField, profile.allow_su);
|
|
||||||
} else {
|
|
||||||
env->SetBooleanField(obj, nonRootUseDefaultField,
|
|
||||||
(jboolean) profile.nrp_config.use_default);
|
|
||||||
env->SetBooleanField(obj, umountModulesField, profile.nrp_config.profile.umount_modules);
|
|
||||||
}
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jboolean JNICALL
|
|
||||||
Java_com_sukisu_ultra_Natives_setAppProfile(JNIEnv *env, jobject clazz, jobject profile) {
|
|
||||||
auto cls = env->FindClass("com/sukisu/ultra/Natives$Profile");
|
|
||||||
|
|
||||||
auto keyField = env->GetFieldID(cls, "name", "Ljava/lang/String;");
|
|
||||||
auto currentUidField = env->GetFieldID(cls, "currentUid", "I");
|
|
||||||
auto allowSuField = env->GetFieldID(cls, "allowSu", "Z");
|
|
||||||
|
|
||||||
auto rootUseDefaultField = env->GetFieldID(cls, "rootUseDefault", "Z");
|
|
||||||
auto rootTemplateField = env->GetFieldID(cls, "rootTemplate", "Ljava/lang/String;");
|
|
||||||
|
|
||||||
auto uidField = env->GetFieldID(cls, "uid", "I");
|
|
||||||
auto gidField = env->GetFieldID(cls, "gid", "I");
|
|
||||||
auto groupsField = env->GetFieldID(cls, "groups", "Ljava/util/List;");
|
|
||||||
auto capabilitiesField = env->GetFieldID(cls, "capabilities", "Ljava/util/List;");
|
|
||||||
auto domainField = env->GetFieldID(cls, "context", "Ljava/lang/String;");
|
|
||||||
auto namespacesField = env->GetFieldID(cls, "namespace", "I");
|
|
||||||
|
|
||||||
auto nonRootUseDefaultField = env->GetFieldID(cls, "nonRootUseDefault", "Z");
|
|
||||||
auto umountModulesField = env->GetFieldID(cls, "umountModules", "Z");
|
|
||||||
|
|
||||||
auto key = env->GetObjectField(profile, keyField);
|
|
||||||
if (!key) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (env->GetStringLength((jstring) key) > KSU_MAX_PACKAGE_NAME) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto cpkg = env->GetStringUTFChars((jstring) key, nullptr);
|
|
||||||
p_key_t p_key = {};
|
|
||||||
strcpy(p_key, cpkg);
|
|
||||||
env->ReleaseStringUTFChars((jstring) key, cpkg);
|
|
||||||
|
|
||||||
auto currentUid = env->GetIntField(profile, currentUidField);
|
|
||||||
|
|
||||||
auto uid = env->GetIntField(profile, uidField);
|
|
||||||
auto gid = env->GetIntField(profile, gidField);
|
|
||||||
auto groups = env->GetObjectField(profile, groupsField);
|
|
||||||
auto capabilities = env->GetObjectField(profile, capabilitiesField);
|
|
||||||
auto domain = env->GetObjectField(profile, domainField);
|
|
||||||
auto allowSu = env->GetBooleanField(profile, allowSuField);
|
|
||||||
auto umountModules = env->GetBooleanField(profile, umountModulesField);
|
|
||||||
|
|
||||||
app_profile p = {};
|
|
||||||
p.version = KSU_APP_PROFILE_VER;
|
|
||||||
|
|
||||||
strcpy(p.key, p_key);
|
|
||||||
p.allow_su = allowSu;
|
|
||||||
p.current_uid = currentUid;
|
|
||||||
|
|
||||||
if (allowSu) {
|
|
||||||
p.rp_config.use_default = env->GetBooleanField(profile, rootUseDefaultField);
|
|
||||||
auto templateName = env->GetObjectField(profile, rootTemplateField);
|
|
||||||
if (templateName) {
|
|
||||||
auto ctemplateName = env->GetStringUTFChars((jstring) templateName, nullptr);
|
|
||||||
strcpy(p.rp_config.template_name, ctemplateName);
|
|
||||||
env->ReleaseStringUTFChars((jstring) templateName, ctemplateName);
|
|
||||||
}
|
|
||||||
|
|
||||||
p.rp_config.profile.uid = uid;
|
|
||||||
p.rp_config.profile.gid = gid;
|
|
||||||
|
|
||||||
int groups_count = getListSize(env, groups);
|
|
||||||
if (groups_count > KSU_MAX_GROUPS) {
|
|
||||||
LOGD("groups count too large: %d", groups_count);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
p.rp_config.profile.groups_count = groups_count;
|
|
||||||
fillArrayWithList(env, groups, p.rp_config.profile.groups, groups_count);
|
|
||||||
|
|
||||||
p.rp_config.profile.capabilities.effective = capListToBits(env, capabilities);
|
|
||||||
|
|
||||||
auto cdomain = env->GetStringUTFChars((jstring) domain, nullptr);
|
|
||||||
strcpy(p.rp_config.profile.selinux_domain, cdomain);
|
|
||||||
env->ReleaseStringUTFChars((jstring) domain, cdomain);
|
|
||||||
|
|
||||||
p.rp_config.profile.namespaces = env->GetIntField(profile, namespacesField);
|
|
||||||
} else {
|
|
||||||
p.nrp_config.use_default = env->GetBooleanField(profile, nonRootUseDefaultField);
|
|
||||||
p.nrp_config.profile.umount_modules = umountModules;
|
|
||||||
}
|
|
||||||
|
|
||||||
return set_app_profile(&p);
|
|
||||||
}
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jboolean JNICALL
|
|
||||||
Java_com_sukisu_ultra_Natives_uidShouldUmount(JNIEnv *env, jobject thiz, jint uid) {
|
|
||||||
return uid_should_umount(uid);
|
|
||||||
}
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jboolean JNICALL
|
|
||||||
Java_com_sukisu_ultra_Natives_isSuEnabled(JNIEnv *env, jobject thiz) {
|
|
||||||
return is_su_enabled();
|
|
||||||
}
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jboolean JNICALL
|
|
||||||
Java_com_sukisu_ultra_Natives_setSuEnabled(JNIEnv *env, jobject thiz, jboolean enabled) {
|
|
||||||
return set_su_enabled(enabled);
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
//
|
|
||||||
// Created by weishu on 2022/12/9.
|
|
||||||
//
|
|
||||||
|
|
||||||
#include <sys/prctl.h>
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
|
|
||||||
#include "ksu.h"
|
|
||||||
|
|
||||||
#define KERNEL_SU_OPTION 0xDEADBEEF
|
|
||||||
|
|
||||||
#define CMD_GRANT_ROOT 0
|
|
||||||
|
|
||||||
#define CMD_BECOME_MANAGER 1
|
|
||||||
#define CMD_GET_VERSION 2
|
|
||||||
#define CMD_ALLOW_SU 3
|
|
||||||
#define CMD_DENY_SU 4
|
|
||||||
#define CMD_GET_SU_LIST 5
|
|
||||||
#define CMD_GET_DENY_LIST 6
|
|
||||||
#define CMD_CHECK_SAFEMODE 9
|
|
||||||
|
|
||||||
#define CMD_GET_APP_PROFILE 10
|
|
||||||
#define CMD_SET_APP_PROFILE 11
|
|
||||||
|
|
||||||
#define CMD_IS_UID_GRANTED_ROOT 12
|
|
||||||
#define CMD_IS_UID_SHOULD_UMOUNT 13
|
|
||||||
#define CMD_IS_SU_ENABLED 14
|
|
||||||
#define CMD_ENABLE_SU 15
|
|
||||||
|
|
||||||
static bool ksuctl(int cmd, void* arg1, void* arg2) {
|
|
||||||
int32_t result = 0;
|
|
||||||
prctl(KERNEL_SU_OPTION, cmd, arg1, arg2, &result);
|
|
||||||
return result == KERNEL_SU_OPTION;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool become_manager(const char* pkg) {
|
|
||||||
char param[128];
|
|
||||||
uid_t uid = getuid();
|
|
||||||
uint32_t userId = uid / 100000;
|
|
||||||
if (userId == 0) {
|
|
||||||
sprintf(param, "/data/data/%s", pkg);
|
|
||||||
} else {
|
|
||||||
snprintf(param, sizeof(param), "/data/user/%d/%s", userId, pkg);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ksuctl(CMD_BECOME_MANAGER, param, nullptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// cache the result to avoid unnecessary syscall
|
|
||||||
static bool is_lkm;
|
|
||||||
int get_version() {
|
|
||||||
int32_t version = -1;
|
|
||||||
int32_t lkm = 0;
|
|
||||||
ksuctl(CMD_GET_VERSION, &version, &lkm);
|
|
||||||
if (!is_lkm && lkm != 0) {
|
|
||||||
is_lkm = true;
|
|
||||||
}
|
|
||||||
return version;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get_allow_list(int *uids, int *size) {
|
|
||||||
return ksuctl(CMD_GET_SU_LIST, uids, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool is_safe_mode() {
|
|
||||||
return ksuctl(CMD_CHECK_SAFEMODE, nullptr, nullptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool is_lkm_mode() {
|
|
||||||
// you should call get_version first!
|
|
||||||
return is_lkm;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool uid_should_umount(int uid) {
|
|
||||||
bool should;
|
|
||||||
return ksuctl(CMD_IS_UID_SHOULD_UMOUNT, reinterpret_cast<void*>(uid), &should) && should;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool set_app_profile(const app_profile *profile) {
|
|
||||||
return ksuctl(CMD_SET_APP_PROFILE, (void*) profile, nullptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get_app_profile(p_key_t key, app_profile *profile) {
|
|
||||||
return ksuctl(CMD_GET_APP_PROFILE, (void*) profile, nullptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool set_su_enabled(bool enabled) {
|
|
||||||
return ksuctl(CMD_ENABLE_SU, (void*) enabled, nullptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool is_su_enabled() {
|
|
||||||
bool enabled = true;
|
|
||||||
// if ksuctl failed, we assume su is enabled, and it cannot be disabled.
|
|
||||||
ksuctl(CMD_IS_SU_ENABLED, &enabled, nullptr);
|
|
||||||
return enabled;
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
//
|
|
||||||
// Created by weishu on 2022/12/9.
|
|
||||||
//
|
|
||||||
|
|
||||||
#ifndef KERNELSU_KSU_H
|
|
||||||
#define KERNELSU_KSU_H
|
|
||||||
|
|
||||||
#include <linux/capability.h>
|
|
||||||
|
|
||||||
bool become_manager(const char *);
|
|
||||||
|
|
||||||
int get_version();
|
|
||||||
|
|
||||||
bool get_allow_list(int *uids, int *size);
|
|
||||||
|
|
||||||
bool uid_should_umount(int uid);
|
|
||||||
|
|
||||||
bool is_safe_mode();
|
|
||||||
|
|
||||||
bool is_lkm_mode();
|
|
||||||
|
|
||||||
#define KSU_APP_PROFILE_VER 2
|
|
||||||
#define KSU_MAX_PACKAGE_NAME 256
|
|
||||||
// NGROUPS_MAX for Linux is 65535 generally, but we only supports 32 groups.
|
|
||||||
#define KSU_MAX_GROUPS 32
|
|
||||||
#define KSU_SELINUX_DOMAIN 64
|
|
||||||
|
|
||||||
using p_key_t = char[KSU_MAX_PACKAGE_NAME];
|
|
||||||
|
|
||||||
struct root_profile {
|
|
||||||
int32_t uid;
|
|
||||||
int32_t gid;
|
|
||||||
|
|
||||||
int32_t groups_count;
|
|
||||||
int32_t groups[KSU_MAX_GROUPS];
|
|
||||||
|
|
||||||
// kernel_cap_t is u32[2] for capabilities v3
|
|
||||||
struct {
|
|
||||||
uint64_t effective;
|
|
||||||
uint64_t permitted;
|
|
||||||
uint64_t inheritable;
|
|
||||||
} capabilities;
|
|
||||||
|
|
||||||
char selinux_domain[KSU_SELINUX_DOMAIN];
|
|
||||||
|
|
||||||
int32_t namespaces;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct non_root_profile {
|
|
||||||
bool umount_modules;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct app_profile {
|
|
||||||
// It may be utilized for backward compatibility, although we have never explicitly made any promises regarding this.
|
|
||||||
uint32_t version;
|
|
||||||
|
|
||||||
// this is usually the package of the app, but can be other value for special apps
|
|
||||||
char key[KSU_MAX_PACKAGE_NAME];
|
|
||||||
int32_t current_uid;
|
|
||||||
bool allow_su;
|
|
||||||
|
|
||||||
union {
|
|
||||||
struct {
|
|
||||||
bool use_default;
|
|
||||||
char template_name[KSU_MAX_PACKAGE_NAME];
|
|
||||||
|
|
||||||
struct root_profile profile;
|
|
||||||
} rp_config;
|
|
||||||
|
|
||||||
struct {
|
|
||||||
bool use_default;
|
|
||||||
|
|
||||||
struct non_root_profile profile;
|
|
||||||
} nrp_config;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
bool set_app_profile(const app_profile *profile);
|
|
||||||
|
|
||||||
bool get_app_profile(p_key_t key, app_profile *profile);
|
|
||||||
|
|
||||||
bool set_su_enabled(bool enabled);
|
|
||||||
|
|
||||||
bool is_su_enabled();
|
|
||||||
|
|
||||||
#endif //KERNELSU_KSU_H
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package com.sukisu.ultra
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import coil.Coil
|
|
||||||
import coil.ImageLoader
|
|
||||||
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
|
|
||||||
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
lateinit var ksuApp: KernelSUApplication
|
|
||||||
|
|
||||||
class KernelSUApplication : Application() {
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
ksuApp = this
|
|
||||||
|
|
||||||
val context = this
|
|
||||||
val iconSize = resources.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
|
||||||
Coil.setImageLoader(
|
|
||||||
ImageLoader.Builder(context)
|
|
||||||
.components {
|
|
||||||
add(AppIconKeyer())
|
|
||||||
add(AppIconFetcher.Factory(iconSize, false, context))
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
|
|
||||||
val webroot = File(dataDir, "webroot")
|
|
||||||
if (!webroot.exists()) {
|
|
||||||
webroot.mkdir()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package com.sukisu.ultra
|
|
||||||
|
|
||||||
import android.system.Os
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author weishu
|
|
||||||
* @date 2022/12/10.
|
|
||||||
*/
|
|
||||||
|
|
||||||
data class KernelVersion(val major: Int, val patchLevel: Int, val subLevel: Int) {
|
|
||||||
override fun toString(): String {
|
|
||||||
return "$major.$patchLevel.$subLevel"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isGKI(): Boolean {
|
|
||||||
|
|
||||||
// kernel 6.x
|
|
||||||
if (major > 5) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// kernel 5.10.x
|
|
||||||
if (major == 5) {
|
|
||||||
return patchLevel >= 10
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseKernelVersion(version: String): KernelVersion {
|
|
||||||
val find = "(\\d+)\\.(\\d+)\\.(\\d+)".toRegex().find(version)
|
|
||||||
return if (find != null) {
|
|
||||||
KernelVersion(find.groupValues[1].toInt(), find.groupValues[2].toInt(), find.groupValues[3].toInt())
|
|
||||||
} else {
|
|
||||||
KernelVersion(-1, -1, -1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getKernelVersion(): KernelVersion {
|
|
||||||
Os.uname().release.let {
|
|
||||||
return parseKernelVersion(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
package com.sukisu.ultra
|
|
||||||
|
|
||||||
import android.os.Parcelable
|
|
||||||
import androidx.annotation.Keep
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author weishu
|
|
||||||
* @date 2022/12/8.
|
|
||||||
*/
|
|
||||||
object Natives {
|
|
||||||
// minimal supported kernel version
|
|
||||||
// 10915: allowlist breaking change, add app profile
|
|
||||||
// 10931: app profile struct add 'version' field
|
|
||||||
// 10946: add capabilities
|
|
||||||
// 10977: change groups_count and groups to avoid overflow write
|
|
||||||
// 11071: Fix the issue of failing to set a custom SELinux type.
|
|
||||||
const val MINIMAL_SUPPORTED_KERNEL = 12800
|
|
||||||
|
|
||||||
// 11640: Support query working mode, LKM or GKI
|
|
||||||
// when MINIMAL_SUPPORTED_KERNEL > 11640, we can remove this constant.
|
|
||||||
const val MINIMAL_SUPPORTED_KERNEL_LKM = 12800
|
|
||||||
|
|
||||||
// 12040: Support disable sucompat mode
|
|
||||||
const val MINIMAL_SUPPORTED_SU_COMPAT = 12800
|
|
||||||
const val KERNEL_SU_DOMAIN = "u:r:su:s0"
|
|
||||||
|
|
||||||
const val ROOT_UID = 0
|
|
||||||
const val ROOT_GID = 0
|
|
||||||
|
|
||||||
init {
|
|
||||||
System.loadLibrary("zako")
|
|
||||||
}
|
|
||||||
|
|
||||||
// become root manager, return true if success.
|
|
||||||
external fun becomeManager(pkg: String?): Boolean
|
|
||||||
val version: Int
|
|
||||||
external get
|
|
||||||
|
|
||||||
// get the uid list of allowed su processes.
|
|
||||||
val allowList: IntArray
|
|
||||||
external get
|
|
||||||
|
|
||||||
val isSafeMode: Boolean
|
|
||||||
external get
|
|
||||||
|
|
||||||
val isLkmMode: Boolean
|
|
||||||
external get
|
|
||||||
|
|
||||||
external fun uidShouldUmount(uid: Int): Boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the profile of the given package.
|
|
||||||
* @param key usually the package name
|
|
||||||
* @return return null if failed.
|
|
||||||
*/
|
|
||||||
external fun getAppProfile(key: String?, uid: Int): Profile
|
|
||||||
external fun setAppProfile(profile: Profile?): Boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `su` compat mode can be disabled temporarily.
|
|
||||||
* 0: disabled
|
|
||||||
* 1: enabled
|
|
||||||
* negative : error
|
|
||||||
*/
|
|
||||||
external fun isSuEnabled(): Boolean
|
|
||||||
external fun setSuEnabled(enabled: Boolean): Boolean
|
|
||||||
|
|
||||||
private const val NON_ROOT_DEFAULT_PROFILE_KEY = "$"
|
|
||||||
private const val NOBODY_UID = 9999
|
|
||||||
|
|
||||||
fun setDefaultUmountModules(umountModules: Boolean): Boolean {
|
|
||||||
Profile(
|
|
||||||
NON_ROOT_DEFAULT_PROFILE_KEY,
|
|
||||||
NOBODY_UID,
|
|
||||||
false,
|
|
||||||
umountModules = umountModules
|
|
||||||
).let {
|
|
||||||
return setAppProfile(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isDefaultUmountModules(): Boolean {
|
|
||||||
getAppProfile(NON_ROOT_DEFAULT_PROFILE_KEY, NOBODY_UID).let {
|
|
||||||
return it.umountModules
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun requireNewKernel(): Boolean {
|
|
||||||
return version < MINIMAL_SUPPORTED_KERNEL
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isKsuValid(pkgName: String?): Boolean {
|
|
||||||
if (becomeManager(pkgName)) {
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Immutable
|
|
||||||
@Parcelize
|
|
||||||
@Keep
|
|
||||||
data class Profile(
|
|
||||||
// and there is a default profile for root and non-root
|
|
||||||
val name: String,
|
|
||||||
// current uid for the package, this is convivent for kernel to check
|
|
||||||
// if the package name doesn't match uid, then it should be invalidated.
|
|
||||||
val currentUid: Int = 0,
|
|
||||||
|
|
||||||
// if this is true, kernel will grant root permission to this package
|
|
||||||
val allowSu: Boolean = false,
|
|
||||||
|
|
||||||
// these are used for root profile
|
|
||||||
val rootUseDefault: Boolean = true,
|
|
||||||
val rootTemplate: String? = null,
|
|
||||||
val uid: Int = ROOT_UID,
|
|
||||||
val gid: Int = ROOT_GID,
|
|
||||||
val groups: List<Int> = mutableListOf(),
|
|
||||||
val capabilities: List<Int> = mutableListOf(),
|
|
||||||
val context: String = KERNEL_SU_DOMAIN,
|
|
||||||
val namespace: Int = Namespace.INHERITED.ordinal,
|
|
||||||
|
|
||||||
val nonRootUseDefault: Boolean = true,
|
|
||||||
val umountModules: Boolean = true,
|
|
||||||
var rules: String = "", // this field is save in ksud!!
|
|
||||||
) : Parcelable {
|
|
||||||
enum class Namespace {
|
|
||||||
INHERITED,
|
|
||||||
GLOBAL,
|
|
||||||
INDIVIDUAL,
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() : this("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,446 +0,0 @@
|
|||||||
package com.sukisu.ultra.flash
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
|
||||||
import androidx.compose.material.icons.filled.Error
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import com.sukisu.ultra.utils.AssetsUtil
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
data class FlashState(
|
|
||||||
val isFlashing: Boolean = false,
|
|
||||||
val isCompleted: Boolean = false,
|
|
||||||
val progress: Float = 0f,
|
|
||||||
val currentStep: String = "",
|
|
||||||
val logs: List<String> = emptyList(),
|
|
||||||
val error: String = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
class HorizonKernelState {
|
|
||||||
private val _state = MutableStateFlow(FlashState())
|
|
||||||
val state: StateFlow<FlashState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
fun updateProgress(progress: Float) {
|
|
||||||
_state.update { it.copy(progress = progress) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateStep(step: String) {
|
|
||||||
_state.update { it.copy(currentStep = step) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addLog(log: String) {
|
|
||||||
_state.update {
|
|
||||||
it.copy(logs = it.logs + log)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setError(error: String) {
|
|
||||||
_state.update { it.copy(error = error) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startFlashing() {
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
isFlashing = true,
|
|
||||||
isCompleted = false,
|
|
||||||
progress = 0f,
|
|
||||||
currentStep = "under preparation...",
|
|
||||||
logs = emptyList(),
|
|
||||||
error = ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun completeFlashing() {
|
|
||||||
_state.update { it.copy(isCompleted = true, progress = 1f) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
_state.value = FlashState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class HorizonKernelWorker(
|
|
||||||
private val context: Context,
|
|
||||||
private val state: HorizonKernelState,
|
|
||||||
private val slot: String? = null
|
|
||||||
) : Thread() {
|
|
||||||
var uri: Uri? = null
|
|
||||||
private lateinit var filePath: String
|
|
||||||
private lateinit var binaryPath: String
|
|
||||||
|
|
||||||
private var onFlashComplete: (() -> Unit)? = null
|
|
||||||
private var originalSlot: String? = null
|
|
||||||
|
|
||||||
fun setOnFlashCompleteListener(listener: () -> Unit) {
|
|
||||||
onFlashComplete = listener
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun run() {
|
|
||||||
state.startFlashing()
|
|
||||||
state.updateStep(context.getString(R.string.horizon_preparing))
|
|
||||||
|
|
||||||
filePath = "${context.filesDir.absolutePath}/${DocumentFile.fromSingleUri(context, uri!!)?.name}"
|
|
||||||
binaryPath = "${context.filesDir.absolutePath}/META-INF/com/google/android/update-binary"
|
|
||||||
|
|
||||||
try {
|
|
||||||
state.updateStep(context.getString(R.string.horizon_cleaning_files))
|
|
||||||
state.updateProgress(0.1f)
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
if (!rootAvailable()) {
|
|
||||||
state.setError(context.getString(R.string.root_required))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state.updateStep(context.getString(R.string.horizon_copying_files))
|
|
||||||
state.updateProgress(0.2f)
|
|
||||||
copy()
|
|
||||||
|
|
||||||
if (!File(filePath).exists()) {
|
|
||||||
state.setError(context.getString(R.string.horizon_copy_failed))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state.updateStep(context.getString(R.string.horizon_extracting_tool))
|
|
||||||
state.updateProgress(0.4f)
|
|
||||||
getBinary()
|
|
||||||
|
|
||||||
state.updateStep(context.getString(R.string.horizon_patching_script))
|
|
||||||
state.updateProgress(0.6f)
|
|
||||||
patch()
|
|
||||||
|
|
||||||
state.updateStep(context.getString(R.string.horizon_flashing))
|
|
||||||
state.updateProgress(0.7f)
|
|
||||||
|
|
||||||
val isAbDevice = isAbDevice()
|
|
||||||
|
|
||||||
if (isAbDevice && slot != null) {
|
|
||||||
state.updateStep(context.getString(R.string.horizon_getting_original_slot))
|
|
||||||
state.updateProgress(0.72f)
|
|
||||||
originalSlot = runCommandGetOutput(true, "getprop ro.boot.slot_suffix")
|
|
||||||
|
|
||||||
state.updateStep(context.getString(R.string.horizon_setting_target_slot))
|
|
||||||
state.updateProgress(0.74f)
|
|
||||||
runCommand(true, "resetprop -n ro.boot.slot_suffix _$slot")
|
|
||||||
}
|
|
||||||
|
|
||||||
flash()
|
|
||||||
|
|
||||||
if (isAbDevice && !originalSlot.isNullOrEmpty()) {
|
|
||||||
state.updateStep(context.getString(R.string.horizon_restoring_original_slot))
|
|
||||||
state.updateProgress(0.8f)
|
|
||||||
runCommand(true, "resetprop ro.boot.slot_suffix $originalSlot")
|
|
||||||
}
|
|
||||||
|
|
||||||
state.updateStep(context.getString(R.string.horizon_flash_complete_status))
|
|
||||||
state.completeFlashing()
|
|
||||||
|
|
||||||
(context as? Activity)?.runOnUiThread {
|
|
||||||
onFlashComplete?.invoke()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
state.setError(e.message ?: context.getString(R.string.horizon_unknown_error))
|
|
||||||
|
|
||||||
if (isAbDevice() && !originalSlot.isNullOrEmpty()) {
|
|
||||||
state.updateStep(context.getString(R.string.horizon_restoring_original_slot))
|
|
||||||
state.updateProgress(0.8f)
|
|
||||||
runCommand(true, "resetprop ro.boot.slot_suffix $originalSlot")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查设备是否为AB分区设备
|
|
||||||
private fun isAbDevice(): Boolean {
|
|
||||||
val abUpdate = runCommandGetOutput(true, "getprop ro.build.ab_update")?.trim() ?: ""
|
|
||||||
if (abUpdate.equals("false", ignoreCase = true) || abUpdate.isEmpty()) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
val slotSuffix = runCommandGetOutput(true, "getprop ro.boot.slot_suffix")
|
|
||||||
return !slotSuffix.isNullOrEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cleanup() {
|
|
||||||
runCommand(false, "find ${context.filesDir.absolutePath} -type f ! -name '*.jpg' ! -name '*.png' -delete")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun copy() {
|
|
||||||
uri?.let { safeUri ->
|
|
||||||
context.contentResolver.openInputStream(safeUri)?.use { input ->
|
|
||||||
FileOutputStream(File(filePath)).use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getBinary() {
|
|
||||||
runCommand(false, "unzip \"$filePath\" \"*/update-binary\" -d ${context.filesDir.absolutePath}")
|
|
||||||
if (!File(binaryPath).exists()) {
|
|
||||||
throw IOException("Failed to extract update-binary")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun patch() {
|
|
||||||
val kernelVersion = runCommandGetOutput(true, "cat /proc/version")
|
|
||||||
val versionRegex = """\d+\.\d+\.\d+""".toRegex()
|
|
||||||
val version = kernelVersion?.let { versionRegex.find(it) }?.value ?: ""
|
|
||||||
val toolName = if (version.isNotEmpty()) {
|
|
||||||
val parts = version.split('.')
|
|
||||||
if (parts.size >= 2) {
|
|
||||||
val major = parts[0].toIntOrNull() ?: 0
|
|
||||||
val minor = parts[1].toIntOrNull() ?: 0
|
|
||||||
if (major < 5 || (major == 5 && minor <= 10)) "5_10" else "5_15+"
|
|
||||||
} else {
|
|
||||||
"5_15+"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"5_15+"
|
|
||||||
}
|
|
||||||
val toolPath = "${context.filesDir.absolutePath}/mkbootfs"
|
|
||||||
AssetsUtil.exportFiles(context, "$toolName-mkbootfs", toolPath)
|
|
||||||
state.addLog("${context.getString(R.string.kernel_version_log, version)} ${context.getString(R.string.tool_version_log, toolName)}")
|
|
||||||
runCommand(false, "sed -i '/chmod -R 755 tools bin;/i cp -f $toolPath \$AKHOME/tools;' $binaryPath")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun flash() {
|
|
||||||
val process = ProcessBuilder("su")
|
|
||||||
.redirectErrorStream(true)
|
|
||||||
.start()
|
|
||||||
|
|
||||||
try {
|
|
||||||
process.outputStream.bufferedWriter().use { writer ->
|
|
||||||
writer.write("export POSTINSTALL=${context.filesDir.absolutePath}\n")
|
|
||||||
|
|
||||||
// 写入槽位信息到临时文件
|
|
||||||
slot?.let { selectedSlot ->
|
|
||||||
writer.write("echo \"$selectedSlot\" > ${context.filesDir.absolutePath}/bootslot\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建刷写命令
|
|
||||||
val flashCommand = buildString {
|
|
||||||
append("sh $binaryPath 3 1 \"$filePath\"")
|
|
||||||
if (slot != null) {
|
|
||||||
append(" \"$(cat ${context.filesDir.absolutePath}/bootslot)\"")
|
|
||||||
}
|
|
||||||
append(" && touch ${context.filesDir.absolutePath}/done\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.write(flashCommand)
|
|
||||||
writer.write("exit\n")
|
|
||||||
writer.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
process.inputStream.bufferedReader().use { reader ->
|
|
||||||
reader.lineSequence().forEach { line ->
|
|
||||||
if (line.startsWith("ui_print")) {
|
|
||||||
val logMessage = line.removePrefix("ui_print").trim()
|
|
||||||
state.addLog(logMessage)
|
|
||||||
|
|
||||||
when {
|
|
||||||
logMessage.contains("extracting", ignoreCase = true) -> {
|
|
||||||
state.updateProgress(0.75f)
|
|
||||||
}
|
|
||||||
logMessage.contains("installing", ignoreCase = true) -> {
|
|
||||||
state.updateProgress(0.85f)
|
|
||||||
}
|
|
||||||
logMessage.contains("complete", ignoreCase = true) -> {
|
|
||||||
state.updateProgress(0.95f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
process.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!File("${context.filesDir.absolutePath}/done").exists()) {
|
|
||||||
throw IOException(context.getString(R.string.flash_failed_message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun runCommand(su: Boolean, cmd: String): Int {
|
|
||||||
val process = ProcessBuilder(if (su) "su" else "sh")
|
|
||||||
.redirectErrorStream(true)
|
|
||||||
.start()
|
|
||||||
|
|
||||||
return try {
|
|
||||||
process.outputStream.bufferedWriter().use { writer ->
|
|
||||||
writer.write("$cmd\n")
|
|
||||||
writer.write("exit\n")
|
|
||||||
writer.flush()
|
|
||||||
}
|
|
||||||
process.waitFor()
|
|
||||||
} finally {
|
|
||||||
process.destroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun runCommandGetOutput(su: Boolean, cmd: String): String? {
|
|
||||||
val process = ProcessBuilder(if (su) "su" else "sh")
|
|
||||||
.redirectErrorStream(true)
|
|
||||||
.start()
|
|
||||||
|
|
||||||
return try {
|
|
||||||
process.outputStream.bufferedWriter().use { writer ->
|
|
||||||
writer.write("$cmd\n")
|
|
||||||
writer.write("exit\n")
|
|
||||||
writer.flush()
|
|
||||||
}
|
|
||||||
process.inputStream.bufferedReader().use { reader ->
|
|
||||||
reader.readText().trim()
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
""
|
|
||||||
} finally {
|
|
||||||
process.destroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun rootAvailable(): Boolean {
|
|
||||||
return try {
|
|
||||||
val process = Runtime.getRuntime().exec("su -c id")
|
|
||||||
val exitValue = process.waitFor()
|
|
||||||
exitValue == 0
|
|
||||||
} catch (_: Exception) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun HorizonKernelFlashProgress(state: FlashState) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.horizon_flash_title),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
LinearProgressIndicator(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
progress = { state.progress },
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = state.currentStep,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.padding(vertical = 4.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (state.logs.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.horizon_logs_label),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.Start)
|
|
||||||
.padding(top = 8.dp, bottom = 4.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(max = 230.dp)
|
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
tonalElevation = 1.dp,
|
|
||||||
shape = MaterialTheme.shapes.small
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
) {
|
|
||||||
state.logs.forEach { log ->
|
|
||||||
Text(
|
|
||||||
text = log,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
modifier = Modifier.padding(vertical = 2.dp),
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
maxLines = 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.error.isNotEmpty()) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 8.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Error,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.padding(end = 8.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = state.error,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (state.isCompleted) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 8.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.CheckCircle,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(end = 8.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.horizon_flash_complete),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package com.sukisu.ultra.profile
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author weishu
|
|
||||||
* @date 2023/6/3.
|
|
||||||
*/
|
|
||||||
enum class Capabilities(val cap: Int, val display: String, val desc: String) {
|
|
||||||
CAP_CHOWN(0, "CHOWN", "Make arbitrary changes to file UIDs and GIDs (see chown(2))"),
|
|
||||||
CAP_DAC_OVERRIDE(1, "DAC_OVERRIDE", "Bypass file read, write, and execute permission checks"),
|
|
||||||
CAP_DAC_READ_SEARCH(2, "DAC_READ_SEARCH", "Bypass file read permission checks and directory read and execute permission checks"),
|
|
||||||
CAP_FOWNER(3, "FOWNER", "Bypass permission checks on operations that normally require the filesystem UID of the process to match the UID of the file (e.g., chmod(2), utime(2)), excluding those operations covered by CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH"),
|
|
||||||
CAP_FSETID(4, "FSETID", "Don’t clear set-user-ID and set-group-ID permission bits when a file is modified; set the set-group-ID bit for a file whose GID does not match the filesystem or any of the supplementary GIDs of the calling process"),
|
|
||||||
CAP_KILL(5, "KILL", "Bypass permission checks for sending signals (see kill(2))."),
|
|
||||||
CAP_SETGID(6, "SETGID", "Make arbitrary manipulations of process GIDs and supplementary GID list; allow setgid(2) manipulation of the caller’s effective and real group IDs"),
|
|
||||||
CAP_SETUID(7, "SETUID", "Make arbitrary manipulations of process UIDs (setuid(2), setreuid(2), setresuid(2), setfsuid(2)); allow changing the current process user IDs; allow changing of the current process group ID to any value in the system’s range of legal group IDs"),
|
|
||||||
CAP_SETPCAP(8, "SETPCAP", "If file capabilities are supported: grant or remove any capability in the caller’s permitted capability set to or from any other process. (This property supersedes the obsolete notion of giving a process all capabilities by granting all capabilities in its permitted set, and of removing all capabilities from a process by granting no capabilities in its permitted set. It does not permit any actions that were not permitted before.)"),
|
|
||||||
CAP_LINUX_IMMUTABLE(9, "LINUX_IMMUTABLE", "Set the FS_APPEND_FL and FS_IMMUTABLE_FL inode flags (see chattr(1))."),
|
|
||||||
CAP_NET_BIND_SERVICE(10, "NET_BIND_SERVICE", "Bind a socket to Internet domain"),
|
|
||||||
CAP_NET_BROADCAST(11, "NET_BROADCAST", "Make socket broadcasts, and listen to multicasts"),
|
|
||||||
CAP_NET_ADMIN(12, "NET_ADMIN", "Perform various network-related operations: interface configuration, administration of IP firewall, masquerading, and accounting, modify routing tables, bind to any address for transparent proxying, set type-of-service (TOS), clear driver statistics, set promiscuous mode, enabling multicasting, use setsockopt(2) to set the following socket options: SO_DEBUG, SO_MARK, SO_PRIORITY (for a priority outside the range 0 to 6), SO_RCVBUFFORCE, and SO_SNDBUFFORCE"),
|
|
||||||
CAP_NET_RAW(13, "NET_RAW", "Use RAW and PACKET sockets"),
|
|
||||||
CAP_IPC_LOCK(14, "IPC_LOCK", "Lock memory (mlock(2), mlockall(2), mmap(2), shmctl(2))"),
|
|
||||||
CAP_IPC_OWNER(15, "IPC_OWNER", "Bypass permission checks for operations on System V IPC objects"),
|
|
||||||
CAP_SYS_MODULE(16, "SYS_MODULE", "Load and unload kernel modules (see init_module(2) and delete_module(2)); in kernels before 2.6.25, this also granted rights for various other operations related to kernel modules"),
|
|
||||||
CAP_SYS_RAWIO(17, "SYS_RAWIO", "Perform I/O port operations (iopl(2) and ioperm(2)); access /proc/kcore"),
|
|
||||||
CAP_SYS_CHROOT(18, "SYS_CHROOT", "Use chroot(2)"),
|
|
||||||
CAP_SYS_PTRACE(19, "SYS_PTRACE", "Trace arbitrary processes using ptrace(2)"),
|
|
||||||
CAP_SYS_PACCT(20, "SYS_PACCT", "Use acct(2)"),
|
|
||||||
CAP_SYS_ADMIN(21, "SYS_ADMIN", "Perform a range of system administration operations including: quotactl(2), mount(2), umount(2), swapon(2), swapoff(2), sethostname(2), and setdomainname(2); set and modify process resource limits (setrlimit(2)); perform various network-related operations (e.g., setting privileged socket options, enabling multicasting, interface configuration); perform various IPC operations (e.g., SysV semaphores, POSIX message queues, System V shared memory); allow reboot and kexec_load(2); override /proc/sys kernel tunables; perform ptrace(2) PTRACE_SECCOMP_GET_FILTER operation; perform some tracing and debugging operations (see ptrace(2)); administer the lifetime of kernel tracepoints (tracefs(5)); perform the KEYCTL_CHOWN and KEYCTL_SETPERM keyctl(2) operations; perform the following keyctl(2) operations: KEYCTL_CAPABILITIES, KEYCTL_CAPSQUASH, and KEYCTL_PKEY_ OPERATIONS; set state for the Extensible Authentication Protocol (EAP) kernel module; and override the RLIMIT_NPROC resource limit; allow ioperm/iopl access to I/O ports"),
|
|
||||||
CAP_SYS_BOOT(22, "SYS_BOOT", "Use reboot(2) and kexec_load(2), reboot and load a new kernel for later execution"),
|
|
||||||
CAP_SYS_NICE(23, "SYS_NICE", "Raise process nice value (nice(2), setpriority(2)) and change the nice value for arbitrary processes; set real-time scheduling policies for calling process, and set scheduling policies and priorities for arbitrary processes (sched_setscheduler(2), sched_setparam(2)"),
|
|
||||||
CAP_SYS_RESOURCE(24, "SYS_RESOURCE", "Override resource Limits. Set resource limits (setrlimit(2), prlimit(2)), override quota limits (quota(2), quotactl(2)), override reserved space on ext2 filesystem (ext2_ioctl(2)), override size restrictions on IPC message queues (msg(2)) and system V shared memory segments (shmget(2)), and override the /proc/sys/fs/pipe-size-max limit"),
|
|
||||||
CAP_SYS_TIME(25, "SYS_TIME", "Set system clock (settimeofday(2), stime(2), adjtimex(2)); set real-time (hardware) clock"),
|
|
||||||
CAP_SYS_TTY_CONFIG(26, "SYS_TTY_CONFIG", "Use vhangup(2); employ various privileged ioctl(2) operations on virtual terminals"),
|
|
||||||
CAP_MKNOD(27, "MKNOD", "Create special files using mknod(2)"),
|
|
||||||
CAP_LEASE(28, "LEASE", "Establish leases on arbitrary files (see fcntl(2))"),
|
|
||||||
CAP_AUDIT_WRITE(29, "AUDIT_WRITE", "Write records to kernel auditing log"),
|
|
||||||
CAP_AUDIT_CONTROL(30, "AUDIT_CONTROL", "Enable and disable kernel auditing; change auditing filter rules; retrieve auditing status and filtering rules"),
|
|
||||||
CAP_SETFCAP(31, "SETFCAP", "If file capabilities are supported: grant or remove any capability in any capability set to any file"),
|
|
||||||
CAP_MAC_OVERRIDE(32, "MAC_OVERRIDE", "Override Mandatory Access Control (MAC). Implemented for the Smack Linux Security Module (LSM)"),
|
|
||||||
CAP_MAC_ADMIN(33, "MAC_ADMIN", "Allow MAC configuration or state changes. Implemented for the Smack LSM"),
|
|
||||||
CAP_SYSLOG(34, "SYSLOG", "Perform privileged syslog(2) operations. See syslog(2) for information on which operations require privilege"),
|
|
||||||
CAP_WAKE_ALARM(35, "WAKE_ALARM", "Trigger something that will wake up the system"),
|
|
||||||
CAP_BLOCK_SUSPEND(36, "BLOCK_SUSPEND", "Employ features that can block system suspend"),
|
|
||||||
CAP_AUDIT_READ(37, "AUDIT_READ", "Allow reading the audit log via a multicast netlink socket"),
|
|
||||||
CAP_PERFMON(38, "PERFMON", "Allow performance monitoring via perf_event_open(2)"),
|
|
||||||
CAP_BPF(39, "BPF", "Allow BPF operations via bpf(2)"),
|
|
||||||
CAP_CHECKPOINT_RESTORE(40, "CHECKPOINT_RESTORE", "Allow processes to be checkpointed via checkpoint/restore in user namespace(2)"),
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package com.sukisu.ultra.profile
|
|
||||||
|
|
||||||
/**
|
|
||||||
* https://cs.android.com/android/platform/superproject/main/+/main:system/core/libcutils/include/private/android_filesystem_config.h
|
|
||||||
* @author weishu
|
|
||||||
* @date 2023/6/3.
|
|
||||||
*/
|
|
||||||
enum class Groups(val gid: Int, val display: String, val desc: String) {
|
|
||||||
ROOT(0, "root", "traditional unix root user"),
|
|
||||||
DAEMON(1, "daemon", "Traditional unix daemon owner."),
|
|
||||||
BIN(2, "bin", "Traditional unix binaries owner."),
|
|
||||||
SYS(3, "sys", "A group with the same gid on Linux/macOS/Android."),
|
|
||||||
SYSTEM(1000, "system", "system server"),
|
|
||||||
RADIO(1001, "radio", "telephony subsystem, RIL"),
|
|
||||||
BLUETOOTH(1002, "bluetooth", "bluetooth subsystem"),
|
|
||||||
GRAPHICS(1003, "graphics", "graphics devices"),
|
|
||||||
INPUT(1004, "input", "input devices"),
|
|
||||||
AUDIO(1005, "audio", "audio devices"),
|
|
||||||
CAMERA(1006, "camera", "camera devices"),
|
|
||||||
LOG(1007, "log", "log devices"),
|
|
||||||
COMPASS(1008, "compass", "compass device"),
|
|
||||||
MOUNT(1009, "mount", "mountd socket"),
|
|
||||||
WIFI(1010, "wifi", "wifi subsystem"),
|
|
||||||
ADB(1011, "adb", "android debug bridge (adbd)"),
|
|
||||||
INSTALL(1012, "install", "group for installing packages"),
|
|
||||||
MEDIA(1013, "media", "mediaserver process"),
|
|
||||||
DHCP(1014, "dhcp", "dhcp client"),
|
|
||||||
SDCARD_RW(1015, "sdcard_rw", "external storage write access"),
|
|
||||||
VPN(1016, "vpn", "vpn system"),
|
|
||||||
KEYSTORE(1017, "keystore", "keystore subsystem"),
|
|
||||||
USB(1018, "usb", "USB devices"),
|
|
||||||
DRM(1019, "drm", "DRM server"),
|
|
||||||
MDNSR(1020, "mdnsr", "MulticastDNSResponder (service discovery)"),
|
|
||||||
GPS(1021, "gps", "GPS daemon"),
|
|
||||||
UNUSED1(1022, "unused1", "deprecated, DO NOT USE"),
|
|
||||||
MEDIA_RW(1023, "media_rw", "internal media storage write access"),
|
|
||||||
MTP(1024, "mtp", "MTP USB driver access"),
|
|
||||||
UNUSED2(1025, "unused2", "deprecated, DO NOT USE"),
|
|
||||||
DRMRPC(1026, "drmrpc", "group for drm rpc"),
|
|
||||||
NFC(1027, "nfc", "nfc subsystem"),
|
|
||||||
SDCARD_R(1028, "sdcard_r", "external storage read access"),
|
|
||||||
CLAT(1029, "clat", "clat part of nat464"),
|
|
||||||
LOOP_RADIO(1030, "loop_radio", "loop radio devices"),
|
|
||||||
MEDIA_DRM(1031, "media_drm", "MediaDrm plugins"),
|
|
||||||
PACKAGE_INFO(1032, "package_info", "access to installed package details"),
|
|
||||||
SDCARD_PICS(1033, "sdcard_pics", "external storage photos access"),
|
|
||||||
SDCARD_AV(1034, "sdcard_av", "external storage audio/video access"),
|
|
||||||
SDCARD_ALL(1035, "sdcard_all", "access all users external storage"),
|
|
||||||
LOGD(1036, "logd", "log daemon"),
|
|
||||||
SHARED_RELRO(1037, "shared_relro", "creator of shared GNU RELRO files"),
|
|
||||||
DBUS(1038, "dbus", "dbus-daemon IPC broker process"),
|
|
||||||
TLSDATE(1039, "tlsdate", "tlsdate unprivileged user"),
|
|
||||||
MEDIA_EX(1040, "media_ex", "mediaextractor process"),
|
|
||||||
AUDIOSERVER(1041, "audioserver", "audioserver process"),
|
|
||||||
METRICS_COLL(1042, "metrics_coll", "metrics_collector process"),
|
|
||||||
METRICSD(1043, "metricsd", "metricsd process"),
|
|
||||||
WEBSERV(1044, "webserv", "webservd process"),
|
|
||||||
DEBUGGERD(1045, "debuggerd", "debuggerd unprivileged user"),
|
|
||||||
MEDIA_CODEC(1046, "media_codec", "media_codec process"),
|
|
||||||
CAMERASERVER(1047, "cameraserver", "cameraserver process"),
|
|
||||||
FIREWALL(1048, "firewall", "firewall process"),
|
|
||||||
TRUNKS(1049, "trunks", "trunksd process"),
|
|
||||||
NVRAM(1050, "nvram", "nvram daemon"),
|
|
||||||
DNS(1051, "dns", "DNS resolution daemon (system: netd)"),
|
|
||||||
DNS_TETHER(1052, "dns_tether", "DNS resolution daemon (tether: dnsmasq)"),
|
|
||||||
WEBVIEW_ZYGOTE(1053, "webview_zygote", "WebView zygote process"),
|
|
||||||
VEHICLE_NETWORK(1054, "vehicle_network", "Vehicle network service"),
|
|
||||||
MEDIA_AUDIO(1055, "media_audio", "GID for audio files on internal media storage"),
|
|
||||||
MEDIA_VIDEO(1056, "media_video", "GID for video files on internal media storage"),
|
|
||||||
MEDIA_IMAGE(1057, "media_image", "GID for image files on internal media storage"),
|
|
||||||
TOMBSTONED(1058, "tombstoned", "tombstoned user"),
|
|
||||||
MEDIA_OBB(1059, "media_obb", "GID for OBB files on internal media storage"),
|
|
||||||
ESE(1060, "ese", "embedded secure element (eSE) subsystem"),
|
|
||||||
OTA_UPDATE(1061, "ota_update", "resource tracking UID for OTA updates"),
|
|
||||||
AUTOMOTIVE_EVS(1062, "automotive_evs", "Automotive rear and surround view system"),
|
|
||||||
LOWPAN(1063, "lowpan", "LoWPAN subsystem"),
|
|
||||||
HSM(1064, "lowpan", "hardware security module subsystem"),
|
|
||||||
RESERVED_DISK(1065, "reserved_disk", "GID that has access to reserved disk space"),
|
|
||||||
STATSD(1066, "statsd", "statsd daemon"),
|
|
||||||
INCIDENTD(1067, "incidentd", "incidentd daemon"),
|
|
||||||
SECURE_ELEMENT(1068, "secure_element", "secure element subsystem"),
|
|
||||||
LMKD(1069, "lmkd", "low memory killer daemon"),
|
|
||||||
LLKD(1070, "llkd", "live lock daemon"),
|
|
||||||
IORAPD(1071, "iorapd", "input/output readahead and pin daemon"),
|
|
||||||
GPU_SERVICE(1072, "gpu_service", "GPU service daemon"),
|
|
||||||
NETWORK_STACK(1073, "network_stack", "network stack service"),
|
|
||||||
GSID(1074, "GSID", "GSI service daemon"),
|
|
||||||
FSVERITY_CERT(1075, "fsverity_cert", "fs-verity key ownership in keystore"),
|
|
||||||
CREDSTORE(1076, "credstore", "identity credential manager service"),
|
|
||||||
EXTERNAL_STORAGE(1077, "external_storage", "Full external storage access including USB OTG volumes"),
|
|
||||||
EXT_DATA_RW(1078, "ext_data_rw", "GID for app-private data directories on external storage"),
|
|
||||||
EXT_OBB_RW(1079, "ext_obb_rw", "GID for OBB directories on external storage"),
|
|
||||||
CONTEXT_HUB(1080, "context_hub", "GID for access to the Context Hub"),
|
|
||||||
VIRTUALIZATIONSERVICE(1081, "virtualizationservice", "VirtualizationService daemon"),
|
|
||||||
ARTD(1082, "artd", "ART Service daemon"),
|
|
||||||
UWB(1083, "uwb", "UWB subsystem"),
|
|
||||||
THREAD_NETWORK(1084, "thread_network", "Thread Network subsystem"),
|
|
||||||
DICED(1085, "diced", "Android's DICE daemon"),
|
|
||||||
DMESGD(1086, "dmesgd", "dmesg parsing daemon for kernel report collection"),
|
|
||||||
JC_WEAVER(1087, "jc_weaver", "Javacard Weaver HAL - to manage omapi ARA rules"),
|
|
||||||
JC_STRONGBOX(1088, "jc_strongbox", "Javacard Strongbox HAL - to manage omapi ARA rules"),
|
|
||||||
JC_IDENTITYCRED(1089, "jc_identitycred", "Javacard Identity Cred HAL - to manage omapi ARA rules"),
|
|
||||||
SDK_SANDBOX(1090, "sdk_sandbox", "SDK sandbox virtual UID"),
|
|
||||||
SECURITY_LOG_WRITER(1091, "security_log_writer", "write to security log"),
|
|
||||||
PRNG_SEEDER(1092, "prng_seeder", "PRNG seeder daemon"),
|
|
||||||
|
|
||||||
SHELL(2000, "shell", "adb and debug shell user"),
|
|
||||||
CACHE(2001, "cache", "cache access"),
|
|
||||||
DIAG(2002, "diag", "access to diagnostic resources"),
|
|
||||||
|
|
||||||
/* The 3000 series are intended for use as supplemental group id's only.
|
|
||||||
* They indicate special Android capabilities that the kernel is aware of. */
|
|
||||||
NET_BT_ADMIN(3001, "net_bt_admin", "bluetooth: create any socket"),
|
|
||||||
NET_BT(3002, "net_bt", "bluetooth: create sco, rfcomm or l2cap sockets"),
|
|
||||||
INET(3003, "inet", "can create AF_INET and AF_INET6 sockets"),
|
|
||||||
NET_RAW(3004, "net_raw", "can create raw INET sockets"),
|
|
||||||
NET_ADMIN(3005, "net_admin", "can configure interfaces and routing tables."),
|
|
||||||
NET_BW_STATS(3006, "net_bw_stats", "read bandwidth statistics"),
|
|
||||||
NET_BW_ACCT(3007, "net_bw_acct", "change bandwidth statistics accounting"),
|
|
||||||
NET_BT_STACK(3008, "net_bt_stack", "access to various bluetooth management functions"),
|
|
||||||
READPROC(3009, "readproc", "Allow /proc read access"),
|
|
||||||
WAKELOCK(3010, "wakelock", "Allow system wakelock read/write access"),
|
|
||||||
UHID(3011, "uhid", "Allow read/write to /dev/uhid node"),
|
|
||||||
READTRACEFS(3012, "readtracefs", "Allow tracefs read"),
|
|
||||||
|
|
||||||
EVERYBODY(9997, "everybody", "Shared external storage read/write"),
|
|
||||||
MISC(9998, "misc", "Access to misc storage"),
|
|
||||||
NOBODY(9999, "nobody", "Reserved"),
|
|
||||||
APP(10000, "app", "Access to app data"),
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.pm.PackageInfo;
|
|
||||||
import android.content.pm.PackageManager;
|
|
||||||
import android.os.IBinder;
|
|
||||||
import android.os.UserHandle;
|
|
||||||
import android.os.UserManager;
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
|
|
||||||
import com.topjohnwu.superuser.ipc.RootService;
|
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import com.sukisu.zako.IKsuInterface;
|
|
||||||
import rikka.parcelablelist.ParcelableListSlice;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author weishu
|
|
||||||
* @date 2023/4/18.
|
|
||||||
*/
|
|
||||||
|
|
||||||
public class KsuService extends RootService {
|
|
||||||
|
|
||||||
private static final String TAG = "KsuService";
|
|
||||||
|
|
||||||
class Stub extends IKsuInterface.Stub {
|
|
||||||
@Override
|
|
||||||
public ParcelableListSlice<PackageInfo> getPackages(int flags) {
|
|
||||||
List<PackageInfo> list = getInstalledPackagesAll(flags);
|
|
||||||
Log.i(TAG, "getPackages: " + list.size());
|
|
||||||
return new ParcelableListSlice<>(list);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public IBinder onBind(@NonNull Intent intent) {
|
|
||||||
return new Stub();
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Integer> getUserIds() {
|
|
||||||
List<Integer> result = new ArrayList<>();
|
|
||||||
UserManager um = (UserManager) getSystemService(Context.USER_SERVICE);
|
|
||||||
List<UserHandle> userProfiles = um.getUserProfiles();
|
|
||||||
for (UserHandle userProfile : userProfiles) {
|
|
||||||
int userId = userProfile.hashCode();
|
|
||||||
result.add(userProfile.hashCode());
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
ArrayList<PackageInfo> getInstalledPackagesAll(int flags) {
|
|
||||||
ArrayList<PackageInfo> packages = new ArrayList<>();
|
|
||||||
for (Integer userId : getUserIds()) {
|
|
||||||
Log.i(TAG, "getInstalledPackagesAll: " + userId);
|
|
||||||
packages.addAll(getInstalledPackagesAsUser(flags, userId));
|
|
||||||
}
|
|
||||||
return packages;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<PackageInfo> getInstalledPackagesAsUser(int flags, int userId) {
|
|
||||||
try {
|
|
||||||
PackageManager pm = getPackageManager();
|
|
||||||
Method getInstalledPackagesAsUser = pm.getClass().getDeclaredMethod("getInstalledPackagesAsUser", int.class, int.class);
|
|
||||||
return (List<PackageInfo>) getInstalledPackagesAsUser.invoke(pm, flags, userId);
|
|
||||||
} catch (Throwable e) {
|
|
||||||
Log.e(TAG, "err", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ArrayList<>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui
|
|
||||||
|
|
||||||
import android.database.ContentObserver
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Handler
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.compose.animation.*
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.navigation.NavBackStackEntry
|
|
||||||
import androidx.navigation.NavHostController
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
|
||||||
import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle
|
|
||||||
import com.ramcosta.composedestinations.generated.NavGraphs
|
|
||||||
import com.ramcosta.composedestinations.spec.NavHostGraphSpec
|
|
||||||
import com.ramcosta.composedestinations.spec.RouteOrDirection
|
|
||||||
import com.ramcosta.composedestinations.utils.isRouteOnBackStackAsState
|
|
||||||
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
|
|
||||||
import io.sukisu.ultra.UltraToolInstall
|
|
||||||
import com.sukisu.ultra.Natives
|
|
||||||
import com.sukisu.ultra.ksuApp
|
|
||||||
import com.sukisu.ultra.ui.screen.BottomBarDestination
|
|
||||||
import com.sukisu.ultra.ui.theme.*
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha
|
|
||||||
import com.sukisu.ultra.ui.util.*
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig.cardElevation
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
|
||||||
private inner class ThemeChangeContentObserver(
|
|
||||||
handler: Handler,
|
|
||||||
private val onThemeChanged: () -> Unit
|
|
||||||
) : ContentObserver(handler) {
|
|
||||||
override fun onChange(selfChange: Boolean) {
|
|
||||||
super.onChange(selfChange)
|
|
||||||
onThemeChanged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
// Enable edge to edge
|
|
||||||
enableEdgeToEdge()
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
window.isNavigationBarContrastEnforced = false
|
|
||||||
}
|
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
val prefs = getSharedPreferences("settings", MODE_PRIVATE)
|
|
||||||
val isFirstRun = prefs.getBoolean("is_first_run", true)
|
|
||||||
|
|
||||||
if (isFirstRun) {
|
|
||||||
ThemeConfig.preventBackgroundRefresh = false
|
|
||||||
getSharedPreferences("theme_prefs", MODE_PRIVATE).edit {
|
|
||||||
putBoolean("prevent_background_refresh", false)
|
|
||||||
}
|
|
||||||
prefs.edit { putBoolean("is_first_run", false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载保存的背景设置
|
|
||||||
loadThemeMode()
|
|
||||||
loadThemeColors()
|
|
||||||
loadDynamicColorState()
|
|
||||||
CardConfig.load(applicationContext)
|
|
||||||
|
|
||||||
val contentObserver = ThemeChangeContentObserver(Handler(mainLooper)) {
|
|
||||||
runOnUiThread {
|
|
||||||
if (!ThemeConfig.preventBackgroundRefresh) {
|
|
||||||
ThemeConfig.backgroundImageLoaded = false
|
|
||||||
loadCustomBackground()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
contentResolver.registerContentObserver(
|
|
||||||
android.provider.Settings.System.getUriFor("ui_night_mode"),
|
|
||||||
false,
|
|
||||||
contentObserver
|
|
||||||
)
|
|
||||||
|
|
||||||
val destroyListeners = mutableListOf<() -> Unit>()
|
|
||||||
destroyListeners.add {
|
|
||||||
contentResolver.unregisterContentObserver(contentObserver)
|
|
||||||
}
|
|
||||||
|
|
||||||
val isManager = Natives.becomeManager(ksuApp.packageName)
|
|
||||||
if (isManager) {
|
|
||||||
install()
|
|
||||||
UltraToolInstall.tryToInstall()
|
|
||||||
}
|
|
||||||
|
|
||||||
setContent {
|
|
||||||
KernelSUTheme {
|
|
||||||
val navController = rememberNavController()
|
|
||||||
val snackBarHostState = remember { SnackbarHostState() }
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
bottomBar = { BottomBar(navController) },
|
|
||||||
contentWindowInsets = WindowInsets(0, 0, 0, 0)
|
|
||||||
) { innerPadding ->
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalSnackbarHost provides snackBarHostState
|
|
||||||
) {
|
|
||||||
DestinationsNavHost(
|
|
||||||
modifier = Modifier.padding(innerPadding),
|
|
||||||
navGraph = NavGraphs.root as NavHostGraphSpec,
|
|
||||||
navController = navController,
|
|
||||||
defaultTransitions = object : NavHostAnimatedDestinationStyle() {
|
|
||||||
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition
|
|
||||||
get() = { fadeIn(animationSpec = tween(340)) }
|
|
||||||
override val exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition
|
|
||||||
get() = { fadeOut(animationSpec = tween(340)) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
super.onPause()
|
|
||||||
CardConfig.save(applicationContext)
|
|
||||||
getSharedPreferences("theme_prefs", MODE_PRIVATE).edit() {
|
|
||||||
putBoolean("prevent_background_refresh", true)
|
|
||||||
}
|
|
||||||
ThemeConfig.preventBackgroundRefresh = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
if (!ThemeConfig.backgroundImageLoaded && !ThemeConfig.preventBackgroundRefresh) {
|
|
||||||
loadCustomBackground()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val destroyListeners = mutableListOf<() -> Unit>()
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
destroyListeners.forEach { it() }
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun BottomBar(navController: NavHostController) {
|
|
||||||
val navigator = navController.rememberDestinationsNavigator()
|
|
||||||
val isManager = Natives.becomeManager(ksuApp.packageName)
|
|
||||||
val fullFeatured = isManager && !Natives.requireNewKernel() && rootAvailable()
|
|
||||||
val kpmVersion = getKpmVersion()
|
|
||||||
val containerColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
val cardColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
|
|
||||||
NavigationBar(
|
|
||||||
modifier = Modifier.windowInsetsPadding(
|
|
||||||
WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)
|
|
||||||
),
|
|
||||||
containerColor = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
|
||||||
scrolledContainerColor = containerColor.copy(alpha = cardAlpha)
|
|
||||||
).containerColor,
|
|
||||||
tonalElevation = cardElevation
|
|
||||||
) {
|
|
||||||
BottomBarDestination.entries.forEach { destination ->
|
|
||||||
if (destination == BottomBarDestination.Kpm) {
|
|
||||||
if (kpmVersion.isNotEmpty() && !kpmVersion.startsWith("Error")) {
|
|
||||||
if (!fullFeatured && destination.rootRequired) return@forEach
|
|
||||||
val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction)
|
|
||||||
NavigationBarItem(
|
|
||||||
selected = isCurrentDestOnBackStack,
|
|
||||||
onClick = {
|
|
||||||
if (!isCurrentDestOnBackStack) {
|
|
||||||
navigator.navigate(destination.direction) {
|
|
||||||
popUpTo(NavGraphs.root as RouteOrDirection) {
|
|
||||||
saveState = true
|
|
||||||
}
|
|
||||||
launchSingleTop = true
|
|
||||||
restoreState = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (isCurrentDestOnBackStack) {
|
|
||||||
destination.iconSelected
|
|
||||||
} else {
|
|
||||||
destination.iconNotSelected
|
|
||||||
},
|
|
||||||
contentDescription = stringResource(destination.label),
|
|
||||||
tint = if (isCurrentDestOnBackStack) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
},
|
|
||||||
label = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(destination.label),
|
|
||||||
style = MaterialTheme.typography.labelMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!fullFeatured && destination.rootRequired) return@forEach
|
|
||||||
val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction)
|
|
||||||
NavigationBarItem(
|
|
||||||
selected = isCurrentDestOnBackStack,
|
|
||||||
onClick = {
|
|
||||||
if (!isCurrentDestOnBackStack) {
|
|
||||||
navigator.navigate(destination.direction) {
|
|
||||||
popUpTo(NavGraphs.root as RouteOrDirection) {
|
|
||||||
saveState = true
|
|
||||||
}
|
|
||||||
launchSingleTop = true
|
|
||||||
restoreState = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (isCurrentDestOnBackStack) {
|
|
||||||
destination.iconSelected
|
|
||||||
} else {
|
|
||||||
destination.iconNotSelected
|
|
||||||
},
|
|
||||||
contentDescription = stringResource(destination.label),
|
|
||||||
tint = if (isCurrentDestOnBackStack) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
},
|
|
||||||
label = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(destination.label),
|
|
||||||
style = MaterialTheme.typography.labelMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.component
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.scale
|
|
||||||
import androidx.compose.ui.res.colorResource
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.TextLinkStyles
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.fromHtml
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import com.sukisu.ultra.BuildConfig
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun AboutCard() {
|
|
||||||
ElevatedCard(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(24.dp)
|
|
||||||
) {
|
|
||||||
AboutCardContent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AboutDialog(dismiss: () -> Unit) {
|
|
||||||
Dialog(
|
|
||||||
onDismissRequest = { dismiss() }
|
|
||||||
) {
|
|
||||||
AboutCard()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AboutCardContent() {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Row {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.size(40.dp),
|
|
||||||
color = colorResource(id = R.color.ic_launcher_background),
|
|
||||||
shape = CircleShape
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.drawable.ic_launcher_monochrome),
|
|
||||||
contentDescription = "icon",
|
|
||||||
modifier = Modifier.scale(1.4f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
|
|
||||||
Column {
|
|
||||||
|
|
||||||
Text(
|
|
||||||
stringResource(id = R.string.app_name),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontSize = 18.sp
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
BuildConfig.VERSION_NAME,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
val annotatedString = AnnotatedString.Companion.fromHtml(
|
|
||||||
htmlString = stringResource(
|
|
||||||
id = R.string.about_source_code,
|
|
||||||
"<b><a href=\"https://github.com/ShirkNeko/SukiSU-Ultra\">GitHub</a></b>",
|
|
||||||
"<b><a href=\"https://t.me/SukiKSU\">Telegram</a></b>"
|
|
||||||
),
|
|
||||||
linkStyles = TextLinkStyles(
|
|
||||||
style = SpanStyle(
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
textDecoration = TextDecoration.Underline
|
|
||||||
),
|
|
||||||
pressedStyle = SpanStyle(
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
background = MaterialTheme.colorScheme.secondaryContainer,
|
|
||||||
textDecoration = TextDecoration.Underline
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = annotatedString,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.component
|
|
||||||
|
|
||||||
import android.graphics.text.LineBreaker
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Parcelable
|
|
||||||
import android.text.Layout
|
|
||||||
import android.text.method.LinkMovementMethod
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.runtime.saveable.Saver
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.compose.ui.window.DialogProperties
|
|
||||||
import io.noties.markwon.Markwon
|
|
||||||
import io.noties.markwon.utils.NoCopySpannableFactory
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.channels.ReceiveChannel
|
|
||||||
import kotlinx.coroutines.flow.FlowCollector
|
|
||||||
import kotlinx.coroutines.flow.consumeAsFlow
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
|
|
||||||
private const val TAG = "DialogComponent"
|
|
||||||
|
|
||||||
interface ConfirmDialogVisuals : Parcelable {
|
|
||||||
val title: String
|
|
||||||
val content: String
|
|
||||||
val isMarkdown: Boolean
|
|
||||||
val confirm: String?
|
|
||||||
val dismiss: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
private data class ConfirmDialogVisualsImpl(
|
|
||||||
override val title: String,
|
|
||||||
override val content: String,
|
|
||||||
override val isMarkdown: Boolean,
|
|
||||||
override val confirm: String?,
|
|
||||||
override val dismiss: String?,
|
|
||||||
) : ConfirmDialogVisuals {
|
|
||||||
companion object {
|
|
||||||
val Empty: ConfirmDialogVisuals = ConfirmDialogVisualsImpl("", "", false, null, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DialogHandle {
|
|
||||||
val isShown: Boolean
|
|
||||||
val dialogType: String
|
|
||||||
fun show()
|
|
||||||
fun hide()
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoadingDialogHandle : DialogHandle {
|
|
||||||
suspend fun <R> withLoading(block: suspend () -> R): R
|
|
||||||
fun showLoading()
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed interface ConfirmResult {
|
|
||||||
object Confirmed : ConfirmResult
|
|
||||||
object Canceled : ConfirmResult
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfirmDialogHandle : DialogHandle {
|
|
||||||
val visuals: ConfirmDialogVisuals
|
|
||||||
|
|
||||||
fun showConfirm(
|
|
||||||
title: String,
|
|
||||||
content: String,
|
|
||||||
markdown: Boolean = false,
|
|
||||||
confirm: String? = null,
|
|
||||||
dismiss: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun awaitConfirm(
|
|
||||||
|
|
||||||
title: String,
|
|
||||||
content: String,
|
|
||||||
markdown: Boolean = false,
|
|
||||||
confirm: String? = null,
|
|
||||||
dismiss: String? = null
|
|
||||||
): ConfirmResult
|
|
||||||
}
|
|
||||||
|
|
||||||
private abstract class DialogHandleBase(
|
|
||||||
val visible: MutableState<Boolean>,
|
|
||||||
val coroutineScope: CoroutineScope
|
|
||||||
) : DialogHandle {
|
|
||||||
override val isShown: Boolean
|
|
||||||
get() = visible.value
|
|
||||||
|
|
||||||
override fun show() {
|
|
||||||
coroutineScope.launch {
|
|
||||||
visible.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final override fun hide() {
|
|
||||||
coroutineScope.launch {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun toString(): String {
|
|
||||||
return dialogType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class LoadingDialogHandleImpl(
|
|
||||||
visible: MutableState<Boolean>,
|
|
||||||
coroutineScope: CoroutineScope
|
|
||||||
) : LoadingDialogHandle, DialogHandleBase(visible, coroutineScope) {
|
|
||||||
override suspend fun <R> withLoading(block: suspend () -> R): R {
|
|
||||||
return coroutineScope.async {
|
|
||||||
try {
|
|
||||||
visible.value = true
|
|
||||||
block()
|
|
||||||
} finally {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
}.await()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun showLoading() {
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
|
|
||||||
override val dialogType: String get() = "LoadingDialog"
|
|
||||||
}
|
|
||||||
|
|
||||||
typealias NullableCallback = (() -> Unit)?
|
|
||||||
|
|
||||||
interface ConfirmCallback {
|
|
||||||
|
|
||||||
val onConfirm: NullableCallback
|
|
||||||
|
|
||||||
val onDismiss: NullableCallback
|
|
||||||
|
|
||||||
val isEmpty: Boolean get() = onConfirm == null && onDismiss == null
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
operator fun invoke(onConfirmProvider: () -> NullableCallback, onDismissProvider: () -> NullableCallback): ConfirmCallback {
|
|
||||||
return object : ConfirmCallback {
|
|
||||||
override val onConfirm: NullableCallback
|
|
||||||
get() = onConfirmProvider()
|
|
||||||
override val onDismiss: NullableCallback
|
|
||||||
get() = onDismissProvider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class ConfirmDialogHandleImpl(
|
|
||||||
visible: MutableState<Boolean>,
|
|
||||||
coroutineScope: CoroutineScope,
|
|
||||||
callback: ConfirmCallback,
|
|
||||||
override var visuals: ConfirmDialogVisuals = ConfirmDialogVisualsImpl.Empty,
|
|
||||||
private val resultFlow: ReceiveChannel<ConfirmResult>
|
|
||||||
) : ConfirmDialogHandle, DialogHandleBase(visible, coroutineScope) {
|
|
||||||
private class ResultCollector(
|
|
||||||
private val callback: ConfirmCallback
|
|
||||||
) : FlowCollector<ConfirmResult> {
|
|
||||||
fun handleResult(result: ConfirmResult) {
|
|
||||||
Log.d(TAG, "handleResult: ${result.javaClass.simpleName}")
|
|
||||||
when (result) {
|
|
||||||
ConfirmResult.Confirmed -> onConfirm()
|
|
||||||
ConfirmResult.Canceled -> onDismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onConfirm() {
|
|
||||||
callback.onConfirm?.invoke()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onDismiss() {
|
|
||||||
callback.onDismiss?.invoke()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun emit(value: ConfirmResult) {
|
|
||||||
handleResult(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val resultCollector = ResultCollector(callback)
|
|
||||||
|
|
||||||
private var awaitContinuation: CancellableContinuation<ConfirmResult>? = null
|
|
||||||
|
|
||||||
private val isCallbackEmpty = callback.isEmpty
|
|
||||||
|
|
||||||
init {
|
|
||||||
coroutineScope.launch {
|
|
||||||
resultFlow
|
|
||||||
.consumeAsFlow()
|
|
||||||
.onEach { result ->
|
|
||||||
awaitContinuation?.let {
|
|
||||||
awaitContinuation = null
|
|
||||||
if (it.isActive) {
|
|
||||||
it.resume(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEach { hide() }
|
|
||||||
.collect(resultCollector)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun awaitResult(): ConfirmResult {
|
|
||||||
return suspendCancellableCoroutine {
|
|
||||||
awaitContinuation = it.apply {
|
|
||||||
if (isCallbackEmpty) {
|
|
||||||
invokeOnCancellation {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateVisuals(visuals: ConfirmDialogVisuals) {
|
|
||||||
this.visuals = visuals
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun show() {
|
|
||||||
if (visuals !== ConfirmDialogVisualsImpl.Empty) {
|
|
||||||
super.show()
|
|
||||||
} else {
|
|
||||||
throw UnsupportedOperationException("can't show confirm dialog with the Empty visuals")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun showConfirm(
|
|
||||||
title: String,
|
|
||||||
content: String,
|
|
||||||
markdown: Boolean,
|
|
||||||
confirm: String?,
|
|
||||||
dismiss: String?
|
|
||||||
) {
|
|
||||||
coroutineScope.launch {
|
|
||||||
updateVisuals(ConfirmDialogVisualsImpl(title, content, markdown, confirm, dismiss))
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun awaitConfirm(
|
|
||||||
title: String,
|
|
||||||
content: String,
|
|
||||||
markdown: Boolean,
|
|
||||||
confirm: String?,
|
|
||||||
dismiss: String?
|
|
||||||
): ConfirmResult {
|
|
||||||
coroutineScope.launch {
|
|
||||||
updateVisuals(ConfirmDialogVisualsImpl(title, content, markdown, confirm, dismiss))
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
return awaitResult()
|
|
||||||
}
|
|
||||||
|
|
||||||
override val dialogType: String get() = "ConfirmDialog"
|
|
||||||
|
|
||||||
override fun toString(): String {
|
|
||||||
return "${super.toString()}(visuals: $visuals)"
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun Saver(
|
|
||||||
visible: MutableState<Boolean>,
|
|
||||||
coroutineScope: CoroutineScope,
|
|
||||||
callback: ConfirmCallback,
|
|
||||||
resultChannel: ReceiveChannel<ConfirmResult>
|
|
||||||
) = Saver<ConfirmDialogHandle, ConfirmDialogVisuals>(
|
|
||||||
save = {
|
|
||||||
it.visuals
|
|
||||||
},
|
|
||||||
restore = {
|
|
||||||
Log.d(TAG, "ConfirmDialog restore, visuals: $it")
|
|
||||||
ConfirmDialogHandleImpl(visible, coroutineScope, callback, it, resultChannel)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class CustomDialogHandleImpl(
|
|
||||||
visible: MutableState<Boolean>,
|
|
||||||
coroutineScope: CoroutineScope
|
|
||||||
) : DialogHandleBase(visible, coroutineScope) {
|
|
||||||
override val dialogType: String get() = "CustomDialog"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberLoadingDialog(): LoadingDialogHandle {
|
|
||||||
val visible = remember {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
if (visible.value) {
|
|
||||||
LoadingDialog()
|
|
||||||
}
|
|
||||||
|
|
||||||
return remember {
|
|
||||||
LoadingDialogHandleImpl(visible, coroutineScope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun rememberConfirmDialog(visuals: ConfirmDialogVisuals, callback: ConfirmCallback): ConfirmDialogHandle {
|
|
||||||
val visible = rememberSaveable {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
val resultChannel = remember {
|
|
||||||
Channel<ConfirmResult>()
|
|
||||||
}
|
|
||||||
|
|
||||||
val handle = rememberSaveable(
|
|
||||||
saver = ConfirmDialogHandleImpl.Saver(visible, coroutineScope, callback, resultChannel),
|
|
||||||
init = {
|
|
||||||
ConfirmDialogHandleImpl(visible, coroutineScope, callback, visuals, resultChannel)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (visible.value) {
|
|
||||||
ConfirmDialog(
|
|
||||||
handle.visuals,
|
|
||||||
confirm = { coroutineScope.launch { resultChannel.send(ConfirmResult.Confirmed) } },
|
|
||||||
dismiss = { coroutineScope.launch { resultChannel.send(ConfirmResult.Canceled) } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberConfirmCallback(onConfirm: NullableCallback, onDismiss: NullableCallback): ConfirmCallback {
|
|
||||||
val currentOnConfirm by rememberUpdatedState(newValue = onConfirm)
|
|
||||||
val currentOnDismiss by rememberUpdatedState(newValue = onDismiss)
|
|
||||||
return remember {
|
|
||||||
ConfirmCallback({ currentOnConfirm }, { currentOnDismiss })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberConfirmDialog(onConfirm: NullableCallback = null, onDismiss: NullableCallback = null): ConfirmDialogHandle {
|
|
||||||
return rememberConfirmDialog(rememberConfirmCallback(onConfirm, onDismiss))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberConfirmDialog(callback: ConfirmCallback): ConfirmDialogHandle {
|
|
||||||
return rememberConfirmDialog(ConfirmDialogVisualsImpl.Empty, callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberCustomDialog(composable: @Composable (dismiss: () -> Unit) -> Unit): DialogHandle {
|
|
||||||
val visible = rememberSaveable {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
if (visible.value) {
|
|
||||||
composable { visible.value = false }
|
|
||||||
}
|
|
||||||
return remember {
|
|
||||||
CustomDialogHandleImpl(visible, coroutineScope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LoadingDialog() {
|
|
||||||
Dialog(
|
|
||||||
onDismissRequest = {},
|
|
||||||
properties = DialogProperties(dismissOnClickOutside = false, dismissOnBackPress = false)
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.size(100.dp), shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ConfirmDialog(visuals: ConfirmDialogVisuals, confirm: () -> Unit, dismiss: () -> Unit) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = {
|
|
||||||
dismiss()
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(text = visuals.title)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
if (visuals.isMarkdown) {
|
|
||||||
MarkdownContent(content = visuals.content)
|
|
||||||
} else {
|
|
||||||
Text(text = visuals.content)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = confirm) {
|
|
||||||
Text(text = visuals.confirm ?: stringResource(id = android.R.string.ok))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = dismiss) {
|
|
||||||
Text(text = visuals.dismiss ?: stringResource(id = android.R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MarkdownContent(content: String) {
|
|
||||||
val contentColor = LocalContentColor.current
|
|
||||||
|
|
||||||
AndroidView(
|
|
||||||
factory = { context ->
|
|
||||||
TextView(context).apply {
|
|
||||||
movementMethod = LinkMovementMethod.getInstance()
|
|
||||||
setSpannableFactory(NoCopySpannableFactory.getInstance())
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
breakStrategy = LineBreaker.BREAK_STRATEGY_SIMPLE
|
|
||||||
}
|
|
||||||
hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE
|
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.wrapContentHeight(),
|
|
||||||
update = {
|
|
||||||
Markwon.create(it.context).setMarkdown(it, content)
|
|
||||||
it.setTextColor(contentColor.toArgb())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.component
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.Fullscreen
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.compose.ui.window.DialogProperties
|
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import coil.request.ImageRequest
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import com.sukisu.ultra.ui.util.BackgroundTransformation
|
|
||||||
import com.sukisu.ultra.ui.util.saveTransformedBackground
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
|
||||||
import kotlin.math.max
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ImageEditorDialog(
|
|
||||||
imageUri: Uri,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onConfirm: (Uri) -> Unit
|
|
||||||
) {
|
|
||||||
var scale by remember { mutableFloatStateOf(1f) }
|
|
||||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
|
||||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
|
||||||
val context = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val density = LocalDensity.current
|
|
||||||
var lastScale by remember { mutableFloatStateOf(1f) }
|
|
||||||
var lastOffsetX by remember { mutableFloatStateOf(0f) }
|
|
||||||
var lastOffsetY by remember { mutableFloatStateOf(0f) }
|
|
||||||
var imageSize by remember { mutableStateOf(Size.Zero) }
|
|
||||||
var screenSize by remember { mutableStateOf(Size.Zero) }
|
|
||||||
val animatedScale by animateFloatAsState(
|
|
||||||
targetValue = scale,
|
|
||||||
label = "ScaleAnimation"
|
|
||||||
)
|
|
||||||
val animatedOffsetX by animateFloatAsState(
|
|
||||||
targetValue = offsetX,
|
|
||||||
label = "OffsetXAnimation"
|
|
||||||
)
|
|
||||||
val animatedOffsetY by animateFloatAsState(
|
|
||||||
targetValue = offsetY,
|
|
||||||
label = "OffsetYAnimation"
|
|
||||||
)
|
|
||||||
val updateTransformation = remember {
|
|
||||||
{ newScale: Float, newOffsetX: Float, newOffsetY: Float ->
|
|
||||||
val scaleDiff = kotlin.math.abs(newScale - lastScale)
|
|
||||||
val offsetXDiff = kotlin.math.abs(newOffsetX - lastOffsetX)
|
|
||||||
val offsetYDiff = kotlin.math.abs(newOffsetY - lastOffsetY)
|
|
||||||
if (scaleDiff > 0.01f || offsetXDiff > 1f || offsetYDiff > 1f) {
|
|
||||||
scale = newScale
|
|
||||||
offsetX = newOffsetX
|
|
||||||
offsetY = newOffsetY
|
|
||||||
lastScale = newScale
|
|
||||||
lastOffsetX = newOffsetX
|
|
||||||
lastOffsetY = newOffsetY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val scaleToFullScreen = remember {
|
|
||||||
{
|
|
||||||
if (imageSize.height > 0 && screenSize.height > 0) {
|
|
||||||
val newScale = screenSize.height / imageSize.height
|
|
||||||
updateTransformation(newScale, 0f, 0f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Dialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
properties = DialogProperties(
|
|
||||||
dismissOnBackPress = true,
|
|
||||||
dismissOnClickOutside = false,
|
|
||||||
usePlatformDefaultWidth = false
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black.copy(alpha = 0.9f))
|
|
||||||
.onSizeChanged { size ->
|
|
||||||
screenSize = Size(size.width.toFloat(), size.height.toFloat())
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
model = ImageRequest.Builder(LocalContext.current)
|
|
||||||
.data(imageUri)
|
|
||||||
.crossfade(true)
|
|
||||||
.build(),
|
|
||||||
contentDescription = stringResource(R.string.settings_custom_background),
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.graphicsLayer(
|
|
||||||
scaleX = animatedScale,
|
|
||||||
scaleY = animatedScale,
|
|
||||||
translationX = animatedOffsetX,
|
|
||||||
translationY = animatedOffsetY
|
|
||||||
)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTransformGestures { _, pan, zoom, _ ->
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val newScale = (scale * zoom).coerceIn(0.5f, 3f)
|
|
||||||
val maxOffsetX = max(0f, size.width * (newScale - 1) / 2)
|
|
||||||
val maxOffsetY = max(0f, size.height * (newScale - 1) / 2)
|
|
||||||
val newOffsetX = if (maxOffsetX > 0) {
|
|
||||||
(offsetX + pan.x).coerceIn(-maxOffsetX, maxOffsetX)
|
|
||||||
} else {
|
|
||||||
0f
|
|
||||||
}
|
|
||||||
val newOffsetY = if (maxOffsetY > 0) {
|
|
||||||
(offsetY + pan.y).coerceIn(-maxOffsetY, maxOffsetY)
|
|
||||||
} else {
|
|
||||||
0f
|
|
||||||
}
|
|
||||||
updateTransformation(newScale, newOffsetX, newOffsetY)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
updateTransformation(lastScale, lastOffsetX, lastOffsetY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onSizeChanged { size ->
|
|
||||||
imageSize = Size(size.width.toFloat(), size.height.toFloat())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
.align(Alignment.TopCenter),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
IconButton(
|
|
||||||
onClick = onDismiss,
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(Color.Black.copy(alpha = 0.6f))
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Close,
|
|
||||||
contentDescription = stringResource(R.string.cancel),
|
|
||||||
tint = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(
|
|
||||||
onClick = { scaleToFullScreen() },
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(Color.Black.copy(alpha = 0.6f))
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Fullscreen,
|
|
||||||
contentDescription = stringResource(R.string.reprovision),
|
|
||||||
tint = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val transformation = BackgroundTransformation(scale, offsetX, offsetY)
|
|
||||||
val savedUri = context.saveTransformedBackground(imageUri, transformation)
|
|
||||||
savedUri?.let { onConfirm(it) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(Color.Black.copy(alpha = 0.6f))
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = stringResource(R.string.confirm),
|
|
||||||
tint = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(Color.Black.copy(alpha = 0.6f))
|
|
||||||
.padding(16.dp)
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.image_editor_hint),
|
|
||||||
color = Color.White,
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.component
|
|
||||||
|
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.input.key.KeyEvent
|
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun KeyEventBlocker(predicate: (KeyEvent) -> Boolean) {
|
|
||||||
val requester = remember { FocusRequester() }
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.onKeyEvent {
|
|
||||||
predicate(it)
|
|
||||||
}
|
|
||||||
.focusRequester(requester)
|
|
||||||
.focusable()
|
|
||||||
)
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
requester.requestFocus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.component
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.only
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig
|
|
||||||
|
|
||||||
private const val TAG = "SearchBar"
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun SearchAppBar(
|
|
||||||
title: @Composable () -> Unit,
|
|
||||||
searchText: String,
|
|
||||||
onSearchTextChange: (String) -> Unit,
|
|
||||||
onClearClick: () -> Unit,
|
|
||||||
onBackClick: (() -> Unit)? = null,
|
|
||||||
onConfirm: (() -> Unit)? = null,
|
|
||||||
dropdownContent: @Composable (() -> Unit)? = null,
|
|
||||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
|
||||||
) {
|
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
var onSearch by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// 获取卡片颜色和透明度
|
|
||||||
val cardColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
val cardAlpha = CardConfig.cardAlpha
|
|
||||||
|
|
||||||
if (onSearch) {
|
|
||||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
|
||||||
}
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
keyboardController?.hide()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Box {
|
|
||||||
AnimatedVisibility(
|
|
||||||
modifier = Modifier.align(Alignment.CenterStart),
|
|
||||||
visible = !onSearch,
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
content = { title() }
|
|
||||||
)
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = onSearch,
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut()
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 2.dp, bottom = 2.dp, end = if (onBackClick != null) 0.dp else 14.dp)
|
|
||||||
.focusRequester(focusRequester)
|
|
||||||
.onFocusChanged { focusState ->
|
|
||||||
if (focusState.isFocused) onSearch = true
|
|
||||||
Log.d(TAG, "onFocusChanged: $focusState")
|
|
||||||
},
|
|
||||||
value = searchText,
|
|
||||||
onValueChange = onSearchTextChange,
|
|
||||||
trailingIcon = {
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
onSearch = false
|
|
||||||
keyboardController?.hide()
|
|
||||||
onClearClick()
|
|
||||||
},
|
|
||||||
content = { Icon(Icons.Filled.Close, null) }
|
|
||||||
)
|
|
||||||
},
|
|
||||||
maxLines = 1,
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
|
|
||||||
keyboardActions = KeyboardActions(onDone = {
|
|
||||||
keyboardController?.hide()
|
|
||||||
onConfirm?.invoke()
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
if (onBackClick != null) {
|
|
||||||
IconButton(
|
|
||||||
onClick = onBackClick,
|
|
||||||
content = { Icon(Icons.AutoMirrored.Outlined.ArrowBack, null) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = !onSearch
|
|
||||||
) {
|
|
||||||
IconButton(
|
|
||||||
onClick = { onSearch = true },
|
|
||||||
content = { Icon(Icons.Filled.Search, null) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dropdownContent != null) {
|
|
||||||
dropdownContent()
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
|
||||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
|
||||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
private fun SearchAppBarPreview() {
|
|
||||||
var searchText by remember { mutableStateOf("") }
|
|
||||||
SearchAppBar(
|
|
||||||
title = { Text("Search text") },
|
|
||||||
searchText = searchText,
|
|
||||||
onSearchTextChange = { searchText = it },
|
|
||||||
onClearClick = { searchText = "" }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.component
|
|
||||||
|
|
||||||
import androidx.compose.foundation.LocalIndication
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.selection.toggleable
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SwitchItem(
|
|
||||||
icon: ImageVector? = null,
|
|
||||||
title: String,
|
|
||||||
summary: String? = null,
|
|
||||||
checked: Boolean,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
onCheckedChange: (Boolean) -> Unit
|
|
||||||
) {
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier
|
|
||||||
.toggleable(
|
|
||||||
value = checked,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
role = Role.Switch,
|
|
||||||
enabled = enabled,
|
|
||||||
indication = LocalIndication.current,
|
|
||||||
onValueChange = onCheckedChange
|
|
||||||
),
|
|
||||||
headlineContent = {
|
|
||||||
Text(title)
|
|
||||||
},
|
|
||||||
leadingContent = icon?.let {
|
|
||||||
{ Icon(icon, title) }
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Switch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = onCheckedChange,
|
|
||||||
interactionSource = interactionSource
|
|
||||||
)
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
if (summary != null) {
|
|
||||||
Text(summary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RadioItem(
|
|
||||||
title: String,
|
|
||||||
selected: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Text(title)
|
|
||||||
},
|
|
||||||
leadingContent = {
|
|
||||||
RadioButton(selected = selected, onClick = onClick)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.component
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import com.sukisu.ultra.ui.theme.ThemeConfig
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.SdStorage
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 槽位选择对话框组件
|
|
||||||
* 用于HorizonKernel刷写时选择目标槽位
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun SlotSelectionDialog(
|
|
||||||
show: Boolean,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onSlotSelected: (String) -> Unit
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
var currentSlot by remember { mutableStateOf<String?>(null) }
|
|
||||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
try {
|
|
||||||
currentSlot = getCurrentSlot(context)
|
|
||||||
errorMessage = null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
errorMessage = e.message
|
|
||||||
currentSlot = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (show) {
|
|
||||||
val cardColor = if (!ThemeConfig.useDynamicColor) {
|
|
||||||
ThemeConfig.currentTheme.ButtonContrast
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.select_slot_title),
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(vertical = 8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
if (errorMessage != null) {
|
|
||||||
Text(
|
|
||||||
text = "Error: $errorMessage",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
id = R.string.current_slot,
|
|
||||||
currentSlot ?: "Unknown"
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.select_slot_description),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
// Horizontal arrangement for slot options with highlighted current slot
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.horizontalScroll(rememberScrollState()),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
val slotOptions = listOf(
|
|
||||||
ListOption(
|
|
||||||
titleText = stringResource(id = R.string.slot_a),
|
|
||||||
subtitleText = if (currentSlot == "a" || currentSlot == "_a") stringResource(id = R.string.currently_selected) else null,
|
|
||||||
icon = Icons.Filled.SdStorage
|
|
||||||
),
|
|
||||||
ListOption(
|
|
||||||
titleText = stringResource(id = R.string.slot_b),
|
|
||||||
subtitleText = if (currentSlot == "b" || currentSlot == "_b") stringResource(id = R.string.currently_selected) else null,
|
|
||||||
icon = Icons.Filled.SdStorage
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
slotOptions.forEachIndexed { index, option ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(horizontal = 8.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.background(
|
|
||||||
color = if (option.subtitleText != null) {
|
|
||||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.9f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.clickable {
|
|
||||||
onSlotSelected(
|
|
||||||
when (index) {
|
|
||||||
0 -> "a"
|
|
||||||
else -> "b"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(vertical = 12.dp, horizontal = 16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = option.icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (option.subtitleText != null) {
|
|
||||||
MaterialTheme.colorScheme.onPrimary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(end = 16.dp)
|
|
||||||
.size(24.dp)
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = option.titleText,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = if (option.subtitleText != null) {
|
|
||||||
MaterialTheme.colorScheme.onPrimary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
}
|
|
||||||
)
|
|
||||||
option.subtitleText?.let {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = if (true) {
|
|
||||||
MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
currentSlot?.let { onSlotSelected(it) }
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(android.R.string.ok),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = onDismiss
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(android.R.string.cancel),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
containerColor = cardColor,
|
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
|
||||||
tonalElevation = 4.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data class for list options
|
|
||||||
data class ListOption(
|
|
||||||
val titleText: String,
|
|
||||||
val subtitleText: String?,
|
|
||||||
val icon: ImageVector
|
|
||||||
)
|
|
||||||
|
|
||||||
// Utility function to get current slot
|
|
||||||
private fun getCurrentSlot(context: Context): String? {
|
|
||||||
return runCommandGetOutput(true, "getprop ro.boot.slot_suffix")?.let {
|
|
||||||
if (it.startsWith("_")) it.substring(1) else it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun runCommandGetOutput(su: Boolean, cmd: String): String? {
|
|
||||||
return try {
|
|
||||||
val process = ProcessBuilder(if (su) "su" else "sh").start()
|
|
||||||
process.outputStream.bufferedWriter().use { writer ->
|
|
||||||
writer.write("$cmd\n")
|
|
||||||
writer.write("exit\n")
|
|
||||||
writer.flush()
|
|
||||||
}
|
|
||||||
process.inputStream.bufferedReader().use { reader ->
|
|
||||||
reader.readText().trim()
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.component
|
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.SwitchDefaults
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SwitchItem(
|
|
||||||
icon: ImageVector,
|
|
||||||
title: String,
|
|
||||||
summary: String? = null,
|
|
||||||
checked: Boolean,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
onCheckedChange: (Boolean) -> Unit
|
|
||||||
) {
|
|
||||||
// 颜色动画
|
|
||||||
val iconTint by animateColorAsState(
|
|
||||||
targetValue = if (checked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
|
||||||
animationSpec = tween(300),
|
|
||||||
label = "iconTint"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 开关颜色
|
|
||||||
val switchColors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = MaterialTheme.colorScheme.primary,
|
|
||||||
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
checkedBorderColor = MaterialTheme.colorScheme.primary,
|
|
||||||
checkedIconColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
uncheckedThumbColor = MaterialTheme.colorScheme.outline,
|
|
||||||
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
|
|
||||||
uncheckedIconColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
disabledCheckedThumbColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
|
|
||||||
disabledCheckedTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
|
|
||||||
disabledCheckedBorderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
|
|
||||||
disabledCheckedIconColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
disabledUncheckedThumbColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
|
|
||||||
disabledUncheckedTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
|
|
||||||
disabledUncheckedBorderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
|
|
||||||
disabledUncheckedIconColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
},
|
|
||||||
supportingContent = summary?.let {
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 3,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
tint = iconTint
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Switch(
|
|
||||||
checked = checked,
|
|
||||||
onCheckedChange = null,
|
|
||||||
enabled = enabled,
|
|
||||||
colors = switchColors
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(enabled = enabled) {
|
|
||||||
onCheckedChange(!checked)
|
|
||||||
}
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.component.profile
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import com.sukisu.ultra.Natives
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import com.sukisu.ultra.ui.component.SwitchItem
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AppProfileConfig(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
fixedName: Boolean,
|
|
||||||
enabled: Boolean,
|
|
||||||
profile: Natives.Profile,
|
|
||||||
onProfileChange: (Natives.Profile) -> Unit,
|
|
||||||
) {
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
if (!fixedName) {
|
|
||||||
OutlinedTextField(
|
|
||||||
label = { Text(stringResource(R.string.profile_name)) },
|
|
||||||
value = profile.name,
|
|
||||||
onValueChange = { onProfileChange(profile.copy(name = it)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SwitchItem(
|
|
||||||
title = stringResource(R.string.profile_umount_modules),
|
|
||||||
summary = stringResource(R.string.profile_umount_modules_summary),
|
|
||||||
checked = if (enabled) {
|
|
||||||
profile.umountModules
|
|
||||||
} else {
|
|
||||||
Natives.isDefaultUmountModules()
|
|
||||||
},
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
onProfileChange(
|
|
||||||
profile.copy(
|
|
||||||
umountModules = it,
|
|
||||||
nonRootUseDefault = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
private fun AppProfileConfigPreview() {
|
|
||||||
var profile by remember { mutableStateOf(Natives.Profile("")) }
|
|
||||||
AppProfileConfig(fixedName = true, enabled = false, profile = profile) {
|
|
||||||
profile = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,480 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.component.profile
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.AssistChip
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedCard
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.core.text.isDigitsOnly
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.Header
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
|
||||||
import com.maxkeppeler.sheets.input.InputDialog
|
|
||||||
import com.maxkeppeler.sheets.input.models.InputHeader
|
|
||||||
import com.maxkeppeler.sheets.input.models.InputSelection
|
|
||||||
import com.maxkeppeler.sheets.input.models.InputTextField
|
|
||||||
import com.maxkeppeler.sheets.input.models.InputTextFieldType
|
|
||||||
import com.maxkeppeler.sheets.input.models.ValidationResult
|
|
||||||
import com.maxkeppeler.sheets.list.ListDialog
|
|
||||||
import com.maxkeppeler.sheets.list.models.ListOption
|
|
||||||
import com.maxkeppeler.sheets.list.models.ListSelection
|
|
||||||
import com.sukisu.ultra.Natives
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import com.sukisu.ultra.profile.Capabilities
|
|
||||||
import com.sukisu.ultra.profile.Groups
|
|
||||||
import com.sukisu.ultra.ui.component.rememberCustomDialog
|
|
||||||
import com.sukisu.ultra.ui.util.isSepolicyValid
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun RootProfileConfig(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
fixedName: Boolean,
|
|
||||||
profile: Natives.Profile,
|
|
||||||
onProfileChange: (Natives.Profile) -> Unit,
|
|
||||||
) {
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
if (!fixedName) {
|
|
||||||
OutlinedTextField(
|
|
||||||
label = { Text(stringResource(R.string.profile_name)) },
|
|
||||||
value = profile.name,
|
|
||||||
onValueChange = { onProfileChange(profile.copy(name = it)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
|
||||||
val currentNamespace = when (profile.namespace) {
|
|
||||||
Natives.Profile.Namespace.INHERITED.ordinal -> stringResource(R.string.profile_namespace_inherited)
|
|
||||||
Natives.Profile.Namespace.GLOBAL.ordinal -> stringResource(R.string.profile_namespace_global)
|
|
||||||
Natives.Profile.Namespace.INDIVIDUAL.ordinal -> stringResource(R.string.profile_namespace_individual)
|
|
||||||
else -> stringResource(R.string.profile_namespace_inherited)
|
|
||||||
}
|
|
||||||
ListItem(headlineContent = {
|
|
||||||
ExposedDropdownMenuBox(
|
|
||||||
expanded = expanded,
|
|
||||||
onExpandedChange = { expanded = !expanded }
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
modifier = Modifier
|
|
||||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
readOnly = true,
|
|
||||||
label = { Text(stringResource(R.string.profile_namespace)) },
|
|
||||||
value = currentNamespace,
|
|
||||||
onValueChange = {},
|
|
||||||
trailingIcon = {
|
|
||||||
if (expanded) Icon(Icons.Filled.ArrowDropUp, null)
|
|
||||||
else Icon(Icons.Filled.ArrowDropDown, null)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ExposedDropdownMenu(
|
|
||||||
expanded = expanded,
|
|
||||||
onDismissRequest = { expanded = false }
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.profile_namespace_inherited)) },
|
|
||||||
onClick = {
|
|
||||||
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INHERITED.ordinal))
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.profile_namespace_global)) },
|
|
||||||
onClick = {
|
|
||||||
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.GLOBAL.ordinal))
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.profile_namespace_individual)) },
|
|
||||||
onClick = {
|
|
||||||
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INDIVIDUAL.ordinal))
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
*/
|
|
||||||
|
|
||||||
UidPanel(uid = profile.uid, label = "uid", onUidChange = {
|
|
||||||
onProfileChange(
|
|
||||||
profile.copy(
|
|
||||||
uid = it,
|
|
||||||
rootUseDefault = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
UidPanel(uid = profile.gid, label = "gid", onUidChange = {
|
|
||||||
onProfileChange(
|
|
||||||
profile.copy(
|
|
||||||
gid = it,
|
|
||||||
rootUseDefault = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
val selectedGroups = profile.groups.ifEmpty { listOf(0) }.let { e ->
|
|
||||||
e.mapNotNull { g ->
|
|
||||||
Groups.entries.find { it.gid == g }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
GroupsPanel(selectedGroups) {
|
|
||||||
onProfileChange(
|
|
||||||
profile.copy(
|
|
||||||
groups = it.map { group -> group.gid }.ifEmpty { listOf(0) },
|
|
||||||
rootUseDefault = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val selectedCaps = profile.capabilities.mapNotNull { e ->
|
|
||||||
Capabilities.entries.find { it.cap == e }
|
|
||||||
}
|
|
||||||
|
|
||||||
CapsPanel(selectedCaps) {
|
|
||||||
onProfileChange(
|
|
||||||
profile.copy(
|
|
||||||
capabilities = it.map { cap -> cap.cap },
|
|
||||||
rootUseDefault = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SELinuxPanel(profile = profile, onSELinuxChange = { domain, rules ->
|
|
||||||
onProfileChange(
|
|
||||||
profile.copy(
|
|
||||||
context = domain,
|
|
||||||
rules = rules,
|
|
||||||
rootUseDefault = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun GroupsPanel(selected: List<Groups>, closeSelection: (selection: Set<Groups>) -> Unit) {
|
|
||||||
val selectGroupsDialog = rememberCustomDialog { dismiss: () -> Unit ->
|
|
||||||
val groups = Groups.entries.toTypedArray().sortedWith(
|
|
||||||
compareBy<Groups> { if (selected.contains(it)) 0 else 1 }
|
|
||||||
.then(compareBy {
|
|
||||||
when (it) {
|
|
||||||
Groups.ROOT -> 0
|
|
||||||
Groups.SYSTEM -> 1
|
|
||||||
Groups.SHELL -> 2
|
|
||||||
else -> Int.MAX_VALUE
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(compareBy { it.name })
|
|
||||||
|
|
||||||
)
|
|
||||||
val options = groups.map { value ->
|
|
||||||
ListOption(
|
|
||||||
titleText = value.display,
|
|
||||||
subtitleText = value.desc,
|
|
||||||
selected = selected.contains(value),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val selection = HashSet(selected)
|
|
||||||
ListDialog(
|
|
||||||
state = rememberUseCaseState(visible = true, onFinishedRequest = {
|
|
||||||
closeSelection(selection)
|
|
||||||
}, onCloseRequest = {
|
|
||||||
dismiss()
|
|
||||||
}),
|
|
||||||
header = Header.Default(
|
|
||||||
title = stringResource(R.string.profile_groups),
|
|
||||||
),
|
|
||||||
selection = ListSelection.Multiple(
|
|
||||||
showCheckBoxes = true,
|
|
||||||
options = options,
|
|
||||||
maxChoices = 32, // Kernel only supports 32 groups at most
|
|
||||||
) { indecies, _ ->
|
|
||||||
// Handle selection
|
|
||||||
selection.clear()
|
|
||||||
indecies.forEach { index ->
|
|
||||||
val group = groups[index]
|
|
||||||
selection.add(group)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
OutlinedCard(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.clickable {
|
|
||||||
selectGroupsDialog.show()
|
|
||||||
}
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.profile_groups))
|
|
||||||
FlowRow {
|
|
||||||
selected.forEach { group ->
|
|
||||||
AssistChip(
|
|
||||||
modifier = Modifier.padding(3.dp),
|
|
||||||
onClick = { /*TODO*/ },
|
|
||||||
label = { Text(group.display) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun CapsPanel(
|
|
||||||
selected: Collection<Capabilities>,
|
|
||||||
closeSelection: (selection: Set<Capabilities>) -> Unit
|
|
||||||
) {
|
|
||||||
val selectCapabilitiesDialog = rememberCustomDialog { dismiss ->
|
|
||||||
val caps = Capabilities.entries.toTypedArray().sortedWith(
|
|
||||||
compareBy<Capabilities> { if (selected.contains(it)) 0 else 1 }
|
|
||||||
.then(compareBy { it.name })
|
|
||||||
)
|
|
||||||
val options = caps.map { value ->
|
|
||||||
ListOption(
|
|
||||||
titleText = value.display,
|
|
||||||
subtitleText = value.desc,
|
|
||||||
selected = selected.contains(value),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val selection = HashSet(selected)
|
|
||||||
ListDialog(
|
|
||||||
state = rememberUseCaseState(visible = true, onFinishedRequest = {
|
|
||||||
closeSelection(selection)
|
|
||||||
}, onCloseRequest = {
|
|
||||||
dismiss()
|
|
||||||
}),
|
|
||||||
header = Header.Default(
|
|
||||||
title = stringResource(R.string.profile_capabilities),
|
|
||||||
),
|
|
||||||
selection = ListSelection.Multiple(
|
|
||||||
showCheckBoxes = true,
|
|
||||||
options = options
|
|
||||||
) { indecies, _ ->
|
|
||||||
// Handle selection
|
|
||||||
selection.clear()
|
|
||||||
indecies.forEach { index ->
|
|
||||||
val group = caps[index]
|
|
||||||
selection.add(group)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
OutlinedCard(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.clickable {
|
|
||||||
selectCapabilitiesDialog.show()
|
|
||||||
}
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.profile_capabilities))
|
|
||||||
FlowRow {
|
|
||||||
selected.forEach { group ->
|
|
||||||
AssistChip(
|
|
||||||
modifier = Modifier.padding(3.dp),
|
|
||||||
onClick = { /*TODO*/ },
|
|
||||||
label = { Text(group.display) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun UidPanel(uid: Int, label: String, onUidChange: (Int) -> Unit) {
|
|
||||||
|
|
||||||
ListItem(headlineContent = {
|
|
||||||
var isError by remember {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
var lastValidUid by remember {
|
|
||||||
mutableIntStateOf(uid)
|
|
||||||
}
|
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
label = { Text(label) },
|
|
||||||
value = uid.toString(),
|
|
||||||
isError = isError,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Number,
|
|
||||||
imeAction = ImeAction.Done
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(onDone = {
|
|
||||||
keyboardController?.hide()
|
|
||||||
}),
|
|
||||||
onValueChange = {
|
|
||||||
if (it.isEmpty()) {
|
|
||||||
onUidChange(0)
|
|
||||||
return@OutlinedTextField
|
|
||||||
}
|
|
||||||
val valid = isTextValidUid(it)
|
|
||||||
|
|
||||||
val targetUid = if (valid) it.toInt() else lastValidUid
|
|
||||||
if (valid) {
|
|
||||||
lastValidUid = it.toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
onUidChange(targetUid)
|
|
||||||
|
|
||||||
isError = !valid
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun SELinuxPanel(
|
|
||||||
profile: Natives.Profile,
|
|
||||||
onSELinuxChange: (domain: String, rules: String) -> Unit
|
|
||||||
) {
|
|
||||||
val editSELinuxDialog = rememberCustomDialog { dismiss ->
|
|
||||||
var domain by remember { mutableStateOf(profile.context) }
|
|
||||||
var rules by remember { mutableStateOf(profile.rules) }
|
|
||||||
|
|
||||||
val inputOptions = listOf(
|
|
||||||
InputTextField(
|
|
||||||
text = domain,
|
|
||||||
header = InputHeader(
|
|
||||||
title = stringResource(id = R.string.profile_selinux_domain),
|
|
||||||
),
|
|
||||||
type = InputTextFieldType.OUTLINED,
|
|
||||||
required = true,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Ascii,
|
|
||||||
imeAction = ImeAction.Next
|
|
||||||
),
|
|
||||||
resultListener = {
|
|
||||||
domain = it ?: ""
|
|
||||||
},
|
|
||||||
validationListener = { value ->
|
|
||||||
// value can be a-zA-Z0-9_
|
|
||||||
val regex = Regex("^[a-z_]+:[a-z0-9_]+:[a-z0-9_]+(:[a-z0-9_]+)?$")
|
|
||||||
if (value?.matches(regex) == true) ValidationResult.Valid
|
|
||||||
else ValidationResult.Invalid("Domain must be in the format of \"user:role:type:level\"")
|
|
||||||
}
|
|
||||||
),
|
|
||||||
InputTextField(
|
|
||||||
text = rules,
|
|
||||||
header = InputHeader(
|
|
||||||
title = stringResource(id = R.string.profile_selinux_rules),
|
|
||||||
),
|
|
||||||
type = InputTextFieldType.OUTLINED,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Ascii,
|
|
||||||
),
|
|
||||||
singleLine = false,
|
|
||||||
resultListener = {
|
|
||||||
rules = it ?: ""
|
|
||||||
},
|
|
||||||
validationListener = { value ->
|
|
||||||
if (isSepolicyValid(value)) ValidationResult.Valid
|
|
||||||
else ValidationResult.Invalid("SELinux rules is invalid!")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
InputDialog(
|
|
||||||
state = rememberUseCaseState(visible = true,
|
|
||||||
onFinishedRequest = {
|
|
||||||
onSELinuxChange(domain, rules)
|
|
||||||
},
|
|
||||||
onCloseRequest = {
|
|
||||||
dismiss()
|
|
||||||
}),
|
|
||||||
header = Header.Default(
|
|
||||||
title = stringResource(R.string.profile_selinux_context),
|
|
||||||
),
|
|
||||||
selection = InputSelection(
|
|
||||||
input = inputOptions,
|
|
||||||
onPositiveClick = { result ->
|
|
||||||
// Handle selection
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ListItem(headlineContent = {
|
|
||||||
OutlinedTextField(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable {
|
|
||||||
editSELinuxDialog.show()
|
|
||||||
},
|
|
||||||
enabled = false,
|
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
|
||||||
disabledTextColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
disabledBorderColor = MaterialTheme.colorScheme.outline,
|
|
||||||
disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
),
|
|
||||||
label = { Text(text = stringResource(R.string.profile_selinux_context)) },
|
|
||||||
value = profile.context,
|
|
||||||
onValueChange = { }
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
private fun RootProfileConfigPreview() {
|
|
||||||
var profile by remember { mutableStateOf(Natives.Profile("")) }
|
|
||||||
RootProfileConfig(fixedName = true, profile = profile) {
|
|
||||||
profile = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isTextValidUid(text: String): Boolean {
|
|
||||||
return text.isNotEmpty() && text.isDigitsOnly() && text.toInt() >= 0 && text.toInt() <= Int.MAX_VALUE
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.component.profile
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ReadMore
|
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
|
||||||
import androidx.compose.material.icons.filled.ArrowDropUp
|
|
||||||
import androidx.compose.material.icons.filled.Create
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MenuAnchorType
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import com.sukisu.ultra.Natives
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import com.sukisu.ultra.ui.util.listAppProfileTemplates
|
|
||||||
import com.sukisu.ultra.ui.util.setSepolicy
|
|
||||||
import com.sukisu.ultra.ui.viewmodel.getTemplateInfoById
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author weishu
|
|
||||||
* @date 2023/10/21.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun TemplateConfig(
|
|
||||||
profile: Natives.Profile,
|
|
||||||
onViewTemplate: (id: String) -> Unit = {},
|
|
||||||
onManageTemplate: () -> Unit = {},
|
|
||||||
onProfileChange: (Natives.Profile) -> Unit
|
|
||||||
) {
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
|
||||||
var template by rememberSaveable {
|
|
||||||
mutableStateOf(profile.rootTemplate ?: "")
|
|
||||||
}
|
|
||||||
val profileTemplates = listAppProfileTemplates()
|
|
||||||
val noTemplates = profileTemplates.isEmpty()
|
|
||||||
|
|
||||||
ListItem(headlineContent = {
|
|
||||||
ExposedDropdownMenuBox(
|
|
||||||
expanded = expanded,
|
|
||||||
onExpandedChange = { expanded = it },
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
modifier = Modifier
|
|
||||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
readOnly = true,
|
|
||||||
label = { Text(stringResource(R.string.profile_template)) },
|
|
||||||
value = template.ifEmpty { "None" },
|
|
||||||
onValueChange = {},
|
|
||||||
trailingIcon = {
|
|
||||||
if (noTemplates) {
|
|
||||||
IconButton(
|
|
||||||
onClick = onManageTemplate
|
|
||||||
) {
|
|
||||||
Icon(Icons.Filled.Create, null)
|
|
||||||
}
|
|
||||||
} else if (expanded) Icon(Icons.Filled.ArrowDropUp, null)
|
|
||||||
else Icon(Icons.Filled.ArrowDropDown, null)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (profileTemplates.isEmpty()) {
|
|
||||||
return@ExposedDropdownMenuBox
|
|
||||||
}
|
|
||||||
ExposedDropdownMenu(
|
|
||||||
expanded = expanded,
|
|
||||||
onDismissRequest = { expanded = false }
|
|
||||||
) {
|
|
||||||
profileTemplates.forEach { tid ->
|
|
||||||
val templateInfo =
|
|
||||||
getTemplateInfoById(tid) ?: return@forEach
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(tid) },
|
|
||||||
onClick = {
|
|
||||||
template = tid
|
|
||||||
if (setSepolicy(tid, templateInfo.rules.joinToString("\n"))) {
|
|
||||||
onProfileChange(
|
|
||||||
profile.copy(
|
|
||||||
rootTemplate = tid,
|
|
||||||
rootUseDefault = false,
|
|
||||||
uid = templateInfo.uid,
|
|
||||||
gid = templateInfo.gid,
|
|
||||||
groups = templateInfo.groups,
|
|
||||||
capabilities = templateInfo.capabilities,
|
|
||||||
context = templateInfo.context,
|
|
||||||
namespace = templateInfo.namespace,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
trailingIcon = {
|
|
||||||
IconButton(onClick = {
|
|
||||||
onViewTemplate(tid)
|
|
||||||
}) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ReadMore, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,593 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.screen
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.Crossfade
|
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.only
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.AccountCircle
|
|
||||||
import androidx.compose.material.icons.filled.Android
|
|
||||||
import androidx.compose.material.icons.filled.Security
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarColors
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.DpOffset
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.dropUnlessResumed
|
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import coil.request.ImageRequest
|
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.AppProfileTemplateScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.TemplateEditorScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
|
||||||
import com.sukisu.ultra.Natives
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import com.sukisu.ultra.ui.component.SwitchItem
|
|
||||||
import com.sukisu.ultra.ui.component.profile.AppProfileConfig
|
|
||||||
import com.sukisu.ultra.ui.component.profile.RootProfileConfig
|
|
||||||
import com.sukisu.ultra.ui.component.profile.TemplateConfig
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig
|
|
||||||
import com.sukisu.ultra.ui.util.LocalSnackbarHost
|
|
||||||
import com.sukisu.ultra.ui.util.forceStopApp
|
|
||||||
import com.sukisu.ultra.ui.util.getSepolicy
|
|
||||||
import com.sukisu.ultra.ui.util.launchApp
|
|
||||||
import com.sukisu.ultra.ui.util.restartApp
|
|
||||||
import com.sukisu.ultra.ui.util.setSepolicy
|
|
||||||
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
|
|
||||||
import com.sukisu.ultra.ui.viewmodel.getTemplateInfoById
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author weishu
|
|
||||||
* @date 2023/5/16.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Destination<RootGraph>
|
|
||||||
@Composable
|
|
||||||
fun AppProfileScreen(
|
|
||||||
navigator: DestinationsNavigator,
|
|
||||||
appInfo: SuperUserViewModel.AppInfo,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val snackBarHost = LocalSnackbarHost.current
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val failToUpdateAppProfile = stringResource(R.string.failed_to_update_app_profile).format(appInfo.label)
|
|
||||||
val failToUpdateSepolicy = stringResource(R.string.failed_to_update_sepolicy).format(appInfo.label)
|
|
||||||
val suNotAllowed = stringResource(R.string.su_not_allowed).format(appInfo.label)
|
|
||||||
|
|
||||||
val packageName = appInfo.packageName
|
|
||||||
val initialProfile = Natives.getAppProfile(packageName, appInfo.uid)
|
|
||||||
if (initialProfile.allowSu) {
|
|
||||||
initialProfile.rules = getSepolicy(packageName)
|
|
||||||
}
|
|
||||||
var profile by rememberSaveable {
|
|
||||||
mutableStateOf(initialProfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
val cardColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
val cardAlpha = CardConfig.cardAlpha
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopBar(
|
|
||||||
title = appInfo.label,
|
|
||||||
packageName = packageName,
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
|
||||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
|
||||||
),
|
|
||||||
onBack = dropUnlessResumed { navigator.popBackStack() },
|
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = { SnackbarHost(hostState = snackBarHost) },
|
|
||||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
|
||||||
) { paddingValues ->
|
|
||||||
AppProfileInner(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(paddingValues)
|
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
packageName = appInfo.packageName,
|
|
||||||
appLabel = appInfo.label,
|
|
||||||
appIcon = {
|
|
||||||
AsyncImage(
|
|
||||||
model = ImageRequest.Builder(context).data(appInfo.packageInfo).crossfade(true).build(),
|
|
||||||
contentDescription = appInfo.label,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(4.dp)
|
|
||||||
.width(48.dp)
|
|
||||||
.height(48.dp)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
profile = profile,
|
|
||||||
onViewTemplate = {
|
|
||||||
getTemplateInfoById(it)?.let { info ->
|
|
||||||
navigator.navigate(TemplateEditorScreenDestination(info))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onManageTemplate = {
|
|
||||||
navigator.navigate(AppProfileTemplateScreenDestination())
|
|
||||||
},
|
|
||||||
onProfileChange = {
|
|
||||||
scope.launch {
|
|
||||||
if (it.allowSu) {
|
|
||||||
// sync with allowlist.c - forbid_system_uid
|
|
||||||
if (appInfo.uid < 2000 && appInfo.uid != 1000) {
|
|
||||||
snackBarHost.showSnackbar(suNotAllowed)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
if (!it.rootUseDefault && it.rules.isNotEmpty() && !setSepolicy(profile.name, it.rules)) {
|
|
||||||
snackBarHost.showSnackbar(failToUpdateSepolicy)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!Natives.setAppProfile(it)) {
|
|
||||||
snackBarHost.showSnackbar(failToUpdateAppProfile.format(appInfo.uid))
|
|
||||||
} else {
|
|
||||||
profile = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AppProfileInner(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
packageName: String,
|
|
||||||
appLabel: String,
|
|
||||||
appIcon: @Composable () -> Unit,
|
|
||||||
profile: Natives.Profile,
|
|
||||||
onViewTemplate: (id: String) -> Unit = {},
|
|
||||||
onManageTemplate: () -> Unit = {},
|
|
||||||
onProfileChange: (Natives.Profile) -> Unit,
|
|
||||||
) {
|
|
||||||
val isRootGranted = profile.allowSu
|
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
ElevatedCard(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
AppMenuBox(packageName) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
text = appLabel,
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
text = packageName,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
},
|
|
||||||
leadingContent = appIcon,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ElevatedCard(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
SwitchItem(
|
|
||||||
icon = Icons.Filled.Security,
|
|
||||||
title = stringResource(id = R.string.superuser),
|
|
||||||
checked = isRootGranted,
|
|
||||||
onCheckedChange = { onProfileChange(profile.copy(allowSu = it)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Crossfade(
|
|
||||||
targetState = isRootGranted,
|
|
||||||
label = "RootAccess"
|
|
||||||
) { current ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(bottom = 6.dp + 48.dp + 6.dp /* SnackBar height */)
|
|
||||||
) {
|
|
||||||
if (current) {
|
|
||||||
val initialMode = if (profile.rootUseDefault) {
|
|
||||||
Mode.Default
|
|
||||||
} else if (profile.rootTemplate != null) {
|
|
||||||
Mode.Template
|
|
||||||
} else {
|
|
||||||
Mode.Custom
|
|
||||||
}
|
|
||||||
var mode by rememberSaveable {
|
|
||||||
mutableStateOf(initialMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
ElevatedCard(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
ProfileBox(mode, true) {
|
|
||||||
// template mode shouldn't change profile here!
|
|
||||||
if (it == Mode.Default || it == Mode.Custom) {
|
|
||||||
onProfileChange(profile.copy(rootUseDefault = it == Mode.Default))
|
|
||||||
}
|
|
||||||
mode = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = mode != Mode.Default,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically()
|
|
||||||
) {
|
|
||||||
ElevatedCard(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
Crossfade(targetState = mode, label = "ProfileMode") { currentMode ->
|
|
||||||
when (currentMode) {
|
|
||||||
Mode.Template -> {
|
|
||||||
TemplateConfig(
|
|
||||||
profile = profile,
|
|
||||||
onViewTemplate = onViewTemplate,
|
|
||||||
onManageTemplate = onManageTemplate,
|
|
||||||
onProfileChange = onProfileChange
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Mode.Custom -> {
|
|
||||||
RootProfileConfig(
|
|
||||||
fixedName = true,
|
|
||||||
profile = profile,
|
|
||||||
onProfileChange = onProfileChange
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val mode = if (profile.nonRootUseDefault) Mode.Default else Mode.Custom
|
|
||||||
|
|
||||||
ElevatedCard(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
ProfileBox(mode, false) {
|
|
||||||
onProfileChange(profile.copy(nonRootUseDefault = (it == Mode.Default)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = mode == Mode.Custom,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically()
|
|
||||||
) {
|
|
||||||
ElevatedCard(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
AppProfileConfig(
|
|
||||||
fixedName = true,
|
|
||||||
profile = profile,
|
|
||||||
enabled = mode == Mode.Custom,
|
|
||||||
onProfileChange = onProfileChange
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum class Mode(@StringRes private val res: Int) {
|
|
||||||
Default(R.string.profile_default), Template(R.string.profile_template), Custom(R.string.profile_custom);
|
|
||||||
|
|
||||||
val text: String
|
|
||||||
@Composable get() = stringResource(res)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun TopBar(
|
|
||||||
title: String,
|
|
||||||
packageName: String,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
colors: TopAppBarColors,
|
|
||||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
|
||||||
) {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = packageName,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.alpha(0.8f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = colors,
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(
|
|
||||||
onClick = onBack,
|
|
||||||
colors = IconButtonDefaults.iconButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.back)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
windowInsets = WindowInsets.safeDrawing.only(
|
|
||||||
WindowInsetsSides.Top + WindowInsetsSides.Horizontal
|
|
||||||
),
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
modifier = Modifier.shadow(
|
|
||||||
elevation = if ((scrollBehavior?.state?.overlappedFraction ?: 0f) > 0.01f)
|
|
||||||
4.dp else 0.dp,
|
|
||||||
spotColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ProfileBox(
|
|
||||||
mode: Mode,
|
|
||||||
hasTemplate: Boolean,
|
|
||||||
onModeChange: (Mode) -> Unit,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.profile),
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
text = mode.text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
},
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.AccountCircle,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = Dp.Hairline,
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
|
|
||||||
) {
|
|
||||||
FilterChip(
|
|
||||||
selected = mode == Mode.Default,
|
|
||||||
onClick = { onModeChange(Mode.Default) },
|
|
||||||
label = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.profile_default),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
shape = MaterialTheme.shapes.small
|
|
||||||
)
|
|
||||||
|
|
||||||
if (hasTemplate) {
|
|
||||||
FilterChip(
|
|
||||||
selected = mode == Mode.Template,
|
|
||||||
onClick = { onModeChange(Mode.Template) },
|
|
||||||
label = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.profile_template),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
shape = MaterialTheme.shapes.small
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
FilterChip(
|
|
||||||
selected = mode == Mode.Custom,
|
|
||||||
onClick = { onModeChange(Mode.Custom) },
|
|
||||||
label = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.profile_custom),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
shape = MaterialTheme.shapes.small
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("UnusedBoxWithConstraintsScope")
|
|
||||||
@Composable
|
|
||||||
private fun AppMenuBox(packageName: String, content: @Composable () -> Unit) {
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
|
||||||
var touchPoint: Offset by remember { mutableStateOf(Offset.Zero) }
|
|
||||||
val density = LocalDensity.current
|
|
||||||
|
|
||||||
BoxWithConstraints(
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onLongPress = {
|
|
||||||
touchPoint = it
|
|
||||||
expanded = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
|
|
||||||
val (offsetX, offsetY) = with(density) {
|
|
||||||
(touchPoint.x.toDp()) to (touchPoint.y.toDp())
|
|
||||||
}
|
|
||||||
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = expanded,
|
|
||||||
offset = DpOffset(offsetX, -offsetY),
|
|
||||||
onDismissRequest = {
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
AppMenuOption(
|
|
||||||
text = stringResource(id = R.string.launch_app),
|
|
||||||
onClick = {
|
|
||||||
expanded = false
|
|
||||||
launchApp(packageName)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
AppMenuOption(
|
|
||||||
text = stringResource(id = R.string.force_stop_app),
|
|
||||||
onClick = {
|
|
||||||
expanded = false
|
|
||||||
forceStopApp(packageName)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
AppMenuOption(
|
|
||||||
text = stringResource(id = R.string.restart_app),
|
|
||||||
onClick = {
|
|
||||||
expanded = false
|
|
||||||
restartApp(packageName)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AppMenuOption(text: String, onClick: () -> Unit) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = onClick
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
private fun AppProfilePreview() {
|
|
||||||
var profile by remember { mutableStateOf(Natives.Profile("")) }
|
|
||||||
Surface {
|
|
||||||
AppProfileInner(
|
|
||||||
packageName = "icu.nullptr.test",
|
|
||||||
appLabel = "Test",
|
|
||||||
appIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Android,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
profile = profile,
|
|
||||||
onProfileChange = {
|
|
||||||
profile = it
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.screen
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.HomeScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.ModuleScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.SuperUserScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.SettingScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.KpmScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
|
|
||||||
enum class BottomBarDestination(
|
|
||||||
val direction: DirectionDestinationSpec,
|
|
||||||
@StringRes val label: Int,
|
|
||||||
val iconSelected: ImageVector,
|
|
||||||
val iconNotSelected: ImageVector,
|
|
||||||
val rootRequired: Boolean,
|
|
||||||
) {
|
|
||||||
Home(HomeScreenDestination, R.string.home, Icons.Filled.Home, Icons.Outlined.Home, false),
|
|
||||||
Kpm(KpmScreenDestination, R.string.kpm_title, Icons.Filled.Build, Icons.Outlined.Build, true),
|
|
||||||
SuperUser(SuperUserScreenDestination, R.string.superuser, Icons.Filled.Security, Icons.Outlined.Security, true),
|
|
||||||
Module(ModuleScreenDestination, R.string.module, Icons.Filled.Apps, Icons.Outlined.Apps, true),
|
|
||||||
Settings(SettingScreenDestination, R.string.settings, Icons.Filled.Settings, Icons.Outlined.Settings, false),
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.screen
|
|
||||||
|
|
||||||
import android.os.Environment
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Save
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.input.key.Key
|
|
||||||
import androidx.compose.ui.input.key.key
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.dropUnlessResumed
|
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import com.sukisu.ultra.ui.component.KeyEventBlocker
|
|
||||||
import com.sukisu.ultra.ui.util.LocalSnackbarHost
|
|
||||||
import com.sukisu.ultra.ui.util.runModuleAction
|
|
||||||
import java.io.File
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@Destination<RootGraph>
|
|
||||||
fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String) {
|
|
||||||
var text by rememberSaveable { mutableStateOf("") }
|
|
||||||
var tempText : String
|
|
||||||
val logContent = rememberSaveable { StringBuilder() }
|
|
||||||
val snackBarHost = LocalSnackbarHost.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
var actionResult: Boolean
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
if (text.isNotEmpty()) {
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
runModuleAction(
|
|
||||||
moduleId = moduleId,
|
|
||||||
onStdout = {
|
|
||||||
tempText = "$it\n"
|
|
||||||
if (tempText.startsWith("[H[J")) { // clear command
|
|
||||||
text = tempText.substring(6)
|
|
||||||
} else {
|
|
||||||
text += tempText
|
|
||||||
}
|
|
||||||
logContent.append(it).append("\n")
|
|
||||||
},
|
|
||||||
onStderr = {
|
|
||||||
logContent.append(it).append("\n")
|
|
||||||
}
|
|
||||||
).let {
|
|
||||||
actionResult = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (actionResult) navigator.popBackStack()
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopBar(
|
|
||||||
onBack = dropUnlessResumed {
|
|
||||||
navigator.popBackStack()
|
|
||||||
},
|
|
||||||
onSave = {
|
|
||||||
scope.launch {
|
|
||||||
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
|
|
||||||
val date = format.format(Date())
|
|
||||||
val file = File(
|
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
|
||||||
"KernelSU_module_action_log_${date}.log"
|
|
||||||
)
|
|
||||||
file.writeText(logContent.toString())
|
|
||||||
snackBarHost.showSnackbar("Log saved to ${file.absolutePath}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = { SnackbarHost(snackBarHost) }
|
|
||||||
) { innerPadding ->
|
|
||||||
KeyEventBlocker {
|
|
||||||
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize(1f)
|
|
||||||
.padding(innerPadding)
|
|
||||||
.verticalScroll(scrollState),
|
|
||||||
) {
|
|
||||||
LaunchedEffect(text) {
|
|
||||||
scrollState.animateScrollTo(scrollState.maxValue)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
modifier = Modifier.padding(8.dp),
|
|
||||||
text = text,
|
|
||||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun TopBar(onBack: () -> Unit = {}, onSave: () -> Unit = {}) {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(stringResource(R.string.action)) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(
|
|
||||||
onClick = onBack
|
|
||||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(onClick = onSave) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Save,
|
|
||||||
contentDescription = stringResource(id = R.string.save_log),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.screen
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Environment
|
|
||||||
import android.os.Parcelable
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material.icons.filled.Save
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.input.key.Key
|
|
||||||
import androidx.compose.ui.input.key.key
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.dropUnlessResumed
|
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
|
||||||
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
import com.sukisu.ultra.ui.component.KeyEventBlocker
|
|
||||||
import com.sukisu.ultra.ui.util.*
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import java.io.File
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
enum class FlashingStatus {
|
|
||||||
FLASHING,
|
|
||||||
SUCCESS,
|
|
||||||
FAILED
|
|
||||||
}
|
|
||||||
|
|
||||||
private var currentFlashingStatus = mutableStateOf(FlashingStatus.FLASHING)
|
|
||||||
|
|
||||||
fun setFlashingStatus(status: FlashingStatus) {
|
|
||||||
currentFlashingStatus.value = status
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
@Destination<RootGraph>
|
|
||||||
fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
|
||||||
var text by rememberSaveable { mutableStateOf("") }
|
|
||||||
var tempText: String
|
|
||||||
val logContent = rememberSaveable { StringBuilder() }
|
|
||||||
var showFloatAction by rememberSaveable { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val snackBarHost = LocalSnackbarHost.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
if (text.isNotEmpty()) {
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
setFlashingStatus(FlashingStatus.FLASHING)
|
|
||||||
flashIt(flashIt, onFinish = { showReboot, code ->
|
|
||||||
if (code != 0) {
|
|
||||||
text += "Error: exit code = $code.\nPlease save and check the log.\n"
|
|
||||||
setFlashingStatus(FlashingStatus.FAILED)
|
|
||||||
} else {
|
|
||||||
setFlashingStatus(FlashingStatus.SUCCESS)
|
|
||||||
}
|
|
||||||
if (showReboot) {
|
|
||||||
text += "\n\n\n"
|
|
||||||
showFloatAction = true
|
|
||||||
}
|
|
||||||
}, onStdout = {
|
|
||||||
tempText = "$it\n"
|
|
||||||
if (tempText.startsWith("[H[J")) { // clear command
|
|
||||||
text = tempText.substring(6)
|
|
||||||
} else {
|
|
||||||
text += tempText
|
|
||||||
}
|
|
||||||
logContent.append(it).append("\n")
|
|
||||||
}, onStderr = {
|
|
||||||
logContent.append(it).append("\n")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopBar(
|
|
||||||
currentFlashingStatus.value,
|
|
||||||
onBack = dropUnlessResumed {
|
|
||||||
navigator.popBackStack()
|
|
||||||
},
|
|
||||||
onSave = {
|
|
||||||
scope.launch {
|
|
||||||
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
|
|
||||||
val date = format.format(Date())
|
|
||||||
val file = File(
|
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
|
||||||
"KernelSU_install_log_${date}.log"
|
|
||||||
)
|
|
||||||
file.writeText(logContent.toString())
|
|
||||||
snackBarHost.showSnackbar("Log saved to ${file.absolutePath}")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
|
||||||
},
|
|
||||||
floatingActionButton = {
|
|
||||||
if (showFloatAction) {
|
|
||||||
val cardColor = MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
val reboot = stringResource(id = R.string.reboot)
|
|
||||||
ExtendedFloatingActionButton(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
reboot()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon = { Icon(Icons.Filled.Refresh, reboot) },
|
|
||||||
text = { Text(text = reboot) },
|
|
||||||
containerColor = cardColor.copy(alpha = 1f),
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
snackbarHost = { SnackbarHost(hostState = snackBarHost) },
|
|
||||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
|
||||||
) { innerPadding ->
|
|
||||||
KeyEventBlocker {
|
|
||||||
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize(1f)
|
|
||||||
.padding(innerPadding)
|
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
|
||||||
.verticalScroll(scrollState),
|
|
||||||
) {
|
|
||||||
LaunchedEffect(text) {
|
|
||||||
scrollState.animateScrollTo(scrollState.maxValue)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
modifier = Modifier.padding(8.dp),
|
|
||||||
text = text,
|
|
||||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
sealed class FlashIt : Parcelable {
|
|
||||||
data class FlashBoot(val boot: Uri? = null, val lkm: LkmSelection, val ota: Boolean) : FlashIt()
|
|
||||||
data class FlashModule(val uri: Uri) : FlashIt()
|
|
||||||
data object FlashRestore : FlashIt()
|
|
||||||
data object FlashUninstall : FlashIt()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun flashIt(
|
|
||||||
flashIt: FlashIt,
|
|
||||||
onFinish: (Boolean, Int) -> Unit,
|
|
||||||
onStdout: (String) -> Unit,
|
|
||||||
onStderr: (String) -> Unit
|
|
||||||
) {
|
|
||||||
when (flashIt) {
|
|
||||||
is FlashIt.FlashBoot -> installBoot(
|
|
||||||
flashIt.boot,
|
|
||||||
flashIt.lkm,
|
|
||||||
flashIt.ota,
|
|
||||||
onFinish,
|
|
||||||
onStdout,
|
|
||||||
onStderr
|
|
||||||
)
|
|
||||||
is FlashIt.FlashModule -> flashModule(flashIt.uri, onFinish, onStdout, onStderr)
|
|
||||||
FlashIt.FlashRestore -> restoreBoot(onFinish, onStdout, onStderr)
|
|
||||||
FlashIt.FlashUninstall -> uninstallPermanently(onFinish, onStdout, onStderr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun TopBar(
|
|
||||||
status: FlashingStatus,
|
|
||||||
onBack: () -> Unit = {},
|
|
||||||
onSave: () -> Unit = {},
|
|
||||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
|
||||||
) {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
when (status) {
|
|
||||||
FlashingStatus.FLASHING -> R.string.flashing
|
|
||||||
FlashingStatus.SUCCESS -> R.string.flash_success
|
|
||||||
FlashingStatus.FAILED -> R.string.flash_failed
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(
|
|
||||||
onClick = onBack
|
|
||||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(onClick = onSave) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Save,
|
|
||||||
contentDescription = "Localized description"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun FlashScreenPreview() {
|
|
||||||
FlashScreen(EmptyDestinationsNavigator, FlashIt.FlashUninstall)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,788 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.screen
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.foundation.LocalIndication
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.only
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.selection.toggleable
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.AutoFixHigh
|
|
||||||
import androidx.compose.material.icons.filled.FileUpload
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.RadioButtonDefaults
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.produceState
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.Header
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
|
||||||
import com.maxkeppeler.sheets.list.ListDialog
|
|
||||||
import com.maxkeppeler.sheets.list.models.ListOption
|
|
||||||
import com.maxkeppeler.sheets.list.models.ListSelection
|
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
|
||||||
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import com.sukisu.ultra.flash.HorizonKernelFlashProgress
|
|
||||||
import com.sukisu.ultra.flash.HorizonKernelState
|
|
||||||
import com.sukisu.ultra.flash.HorizonKernelWorker
|
|
||||||
import com.sukisu.ultra.ui.component.DialogHandle
|
|
||||||
import com.sukisu.ultra.ui.component.SlotSelectionDialog
|
|
||||||
import com.sukisu.ultra.ui.component.rememberConfirmDialog
|
|
||||||
import com.sukisu.ultra.ui.component.rememberCustomDialog
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig.cardElevation
|
|
||||||
import com.sukisu.ultra.ui.theme.getCardColors
|
|
||||||
import com.sukisu.ultra.ui.util.LkmSelection
|
|
||||||
import com.sukisu.ultra.ui.util.getCurrentKmi
|
|
||||||
import com.sukisu.ultra.ui.util.getSupportedKmis
|
|
||||||
import com.sukisu.ultra.ui.util.isAbDevice
|
|
||||||
import com.sukisu.ultra.ui.util.isInitBoot
|
|
||||||
import com.sukisu.ultra.ui.util.rootAvailable
|
|
||||||
import com.sukisu.ultra.getKernelVersion
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author weishu
|
|
||||||
* @date 2024/3/12.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Destination<RootGraph>
|
|
||||||
@Composable
|
|
||||||
fun InstallScreen(navigator: DestinationsNavigator) {
|
|
||||||
var installMethod by remember { mutableStateOf<InstallMethod?>(null) }
|
|
||||||
var lkmSelection by remember { mutableStateOf<LkmSelection>(LkmSelection.KmiNone) }
|
|
||||||
val context = LocalContext.current
|
|
||||||
var showRebootDialog by remember { mutableStateOf(false) }
|
|
||||||
var showSlotSelectionDialog by remember { mutableStateOf(false) }
|
|
||||||
var tempKernelUri by remember { mutableStateOf<Uri?>(null) }
|
|
||||||
val horizonKernelState = remember { HorizonKernelState() }
|
|
||||||
val flashState by horizonKernelState.state.collectAsState()
|
|
||||||
val summary = stringResource(R.string.horizon_kernel_summary)
|
|
||||||
val kernelVersion = getKernelVersion()
|
|
||||||
val isGKI = kernelVersion.isGKI()
|
|
||||||
val isAbDevice = isAbDevice()
|
|
||||||
|
|
||||||
val onFlashComplete = {
|
|
||||||
showRebootDialog = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showRebootDialog) {
|
|
||||||
RebootDialog(
|
|
||||||
show = true,
|
|
||||||
onDismiss = { showRebootDialog = false },
|
|
||||||
onConfirm = {
|
|
||||||
showRebootDialog = false
|
|
||||||
try {
|
|
||||||
val process = Runtime.getRuntime().exec("su")
|
|
||||||
process.outputStream.bufferedWriter().use { writer ->
|
|
||||||
writer.write("svc power reboot\n")
|
|
||||||
writer.write("exit\n")
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
Toast.makeText(context, R.string.failed_reboot, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val onInstall = {
|
|
||||||
installMethod?.let { method ->
|
|
||||||
when (method) {
|
|
||||||
is InstallMethod.HorizonKernel -> {
|
|
||||||
method.uri?.let { uri ->
|
|
||||||
val worker = HorizonKernelWorker(
|
|
||||||
context = context,
|
|
||||||
state = horizonKernelState,
|
|
||||||
slot = method.slot
|
|
||||||
)
|
|
||||||
worker.uri = uri
|
|
||||||
worker.setOnFlashCompleteListener(onFlashComplete)
|
|
||||||
worker.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val flashIt = FlashIt.FlashBoot(
|
|
||||||
boot = if (method is InstallMethod.SelectFile) method.uri else null,
|
|
||||||
lkm = lkmSelection,
|
|
||||||
ota = method is InstallMethod.DirectInstallToInactiveSlot
|
|
||||||
)
|
|
||||||
navigator.navigate(FlashScreenDestination(flashIt))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
// 槽位选择
|
|
||||||
SlotSelectionDialog(
|
|
||||||
show = showSlotSelectionDialog && isAbDevice,
|
|
||||||
onDismiss = { showSlotSelectionDialog = false },
|
|
||||||
onSlotSelected = { slot ->
|
|
||||||
showSlotSelectionDialog = false
|
|
||||||
val horizonMethod = InstallMethod.HorizonKernel(
|
|
||||||
uri = tempKernelUri,
|
|
||||||
slot = slot,
|
|
||||||
summary = summary
|
|
||||||
)
|
|
||||||
installMethod = horizonMethod
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val currentKmi by produceState(initialValue = "") {
|
|
||||||
value = getCurrentKmi()
|
|
||||||
}
|
|
||||||
|
|
||||||
val selectKmiDialog = rememberSelectKmiDialog { kmi ->
|
|
||||||
kmi?.let {
|
|
||||||
lkmSelection = LkmSelection.KmiString(it)
|
|
||||||
onInstall()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val onClickNext = {
|
|
||||||
if (isGKI && lkmSelection == LkmSelection.KmiNone && currentKmi.isBlank()) {
|
|
||||||
selectKmiDialog.show()
|
|
||||||
} else {
|
|
||||||
onInstall()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val selectLkmLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
|
||||||
) {
|
|
||||||
if (it.resultCode == Activity.RESULT_OK) {
|
|
||||||
it.data?.data?.let { uri ->
|
|
||||||
lkmSelection = LkmSelection.LkmUri(uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val onLkmUpload = {
|
|
||||||
selectLkmLauncher.launch(Intent(Intent.ACTION_GET_CONTENT).apply {
|
|
||||||
type = "application/octet-stream"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopBar(
|
|
||||||
onBack = { navigator.popBackStack() },
|
|
||||||
onLkmUpload = onLkmUpload,
|
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
|
||||||
},
|
|
||||||
contentWindowInsets = WindowInsets.safeDrawing.only(
|
|
||||||
WindowInsetsSides.Top + WindowInsetsSides.Horizontal
|
|
||||||
)
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(innerPadding)
|
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(top = 12.dp)
|
|
||||||
) {
|
|
||||||
SelectInstallMethod(
|
|
||||||
isGKI = isGKI,
|
|
||||||
isAbDevice = isAbDevice,
|
|
||||||
onSelected = { method ->
|
|
||||||
if (method is InstallMethod.HorizonKernel && method.uri != null) {
|
|
||||||
if (isAbDevice) {
|
|
||||||
tempKernelUri = method.uri
|
|
||||||
showSlotSelectionDialog = true
|
|
||||||
} else {
|
|
||||||
installMethod = method
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
installMethod = method
|
|
||||||
}
|
|
||||||
horizonKernelState.reset()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = flashState.isFlashing && installMethod is InstallMethod.HorizonKernel,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically()
|
|
||||||
) {
|
|
||||||
HorizonKernelFlashProgress(flashState)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
(lkmSelection as? LkmSelection.LkmUri)?.let {
|
|
||||||
ElevatedCard(
|
|
||||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = 12.dp)
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.shadow(
|
|
||||||
elevation = cardElevation,
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
id = R.string.selected_lkm,
|
|
||||||
it.uri.lastPathSegment ?: "(file)"
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(installMethod as? InstallMethod.HorizonKernel)?.let { method ->
|
|
||||||
if (method.slot != null) {
|
|
||||||
ElevatedCard(
|
|
||||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = 12.dp)
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.shadow(
|
|
||||||
elevation = cardElevation,
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
id = R.string.selected_slot,
|
|
||||||
if (method.slot == "a") stringResource(id = R.string.slot_a)
|
|
||||||
else stringResource(id = R.string.slot_b)
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
enabled = installMethod != null && !flashState.isFlashing,
|
|
||||||
onClick = onClickNext,
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
|
||||||
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(id = R.string.install_next),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RebootDialog(
|
|
||||||
show: Boolean,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onConfirm: () -> Unit
|
|
||||||
) {
|
|
||||||
if (show) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(stringResource(id = R.string.reboot_complete_title)) },
|
|
||||||
text = { Text(stringResource(id = R.string.reboot_complete_msg)) },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onConfirm) {
|
|
||||||
Text(stringResource(id = R.string.yes))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text(stringResource(id = R.string.no))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed class InstallMethod {
|
|
||||||
data class SelectFile(
|
|
||||||
val uri: Uri? = null,
|
|
||||||
@StringRes override val label: Int = R.string.select_file,
|
|
||||||
override val summary: String?
|
|
||||||
) : InstallMethod()
|
|
||||||
|
|
||||||
data object DirectInstall : InstallMethod() {
|
|
||||||
override val label: Int
|
|
||||||
get() = R.string.direct_install
|
|
||||||
}
|
|
||||||
|
|
||||||
data object DirectInstallToInactiveSlot : InstallMethod() {
|
|
||||||
override val label: Int
|
|
||||||
get() = R.string.install_inactive_slot
|
|
||||||
}
|
|
||||||
|
|
||||||
data class HorizonKernel(
|
|
||||||
val uri: Uri? = null,
|
|
||||||
val slot: String? = null,
|
|
||||||
@StringRes override val label: Int = R.string.horizon_kernel,
|
|
||||||
override val summary: String? = null
|
|
||||||
) : InstallMethod()
|
|
||||||
|
|
||||||
abstract val label: Int
|
|
||||||
open val summary: String? = null
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SelectInstallMethod(
|
|
||||||
isGKI: Boolean = false,
|
|
||||||
isAbDevice: Boolean = false,
|
|
||||||
onSelected: (InstallMethod) -> Unit = {}
|
|
||||||
) {
|
|
||||||
val rootAvailable = rootAvailable()
|
|
||||||
val isAbDevice = isAbDevice()
|
|
||||||
val horizonKernelSummary = stringResource(R.string.horizon_kernel_summary)
|
|
||||||
val selectFileTip = stringResource(
|
|
||||||
id = R.string.select_file_tip,
|
|
||||||
if (isInitBoot()) "init_boot" else "boot"
|
|
||||||
)
|
|
||||||
|
|
||||||
val radioOptions = mutableListOf<InstallMethod>(
|
|
||||||
InstallMethod.SelectFile(summary = selectFileTip)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (rootAvailable) {
|
|
||||||
radioOptions.add(InstallMethod.DirectInstall)
|
|
||||||
if (isAbDevice) {
|
|
||||||
radioOptions.add(InstallMethod.DirectInstallToInactiveSlot)
|
|
||||||
}
|
|
||||||
radioOptions.add(InstallMethod.HorizonKernel(summary = horizonKernelSummary))
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectedOption by remember { mutableStateOf<InstallMethod?>(null) }
|
|
||||||
var currentSelectingMethod by remember { mutableStateOf<InstallMethod?>(null) }
|
|
||||||
|
|
||||||
val selectImageLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
|
||||||
) {
|
|
||||||
if (it.resultCode == Activity.RESULT_OK) {
|
|
||||||
it.data?.data?.let { uri ->
|
|
||||||
val option = when (currentSelectingMethod) {
|
|
||||||
is InstallMethod.SelectFile -> InstallMethod.SelectFile(
|
|
||||||
uri,
|
|
||||||
summary = selectFileTip
|
|
||||||
)
|
|
||||||
|
|
||||||
is InstallMethod.HorizonKernel -> InstallMethod.HorizonKernel(
|
|
||||||
uri,
|
|
||||||
summary = horizonKernelSummary
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
option?.let {
|
|
||||||
selectedOption = it
|
|
||||||
onSelected(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val confirmDialog = rememberConfirmDialog(
|
|
||||||
onConfirm = {
|
|
||||||
selectedOption = InstallMethod.DirectInstallToInactiveSlot
|
|
||||||
onSelected(InstallMethod.DirectInstallToInactiveSlot)
|
|
||||||
},
|
|
||||||
onDismiss = null
|
|
||||||
)
|
|
||||||
|
|
||||||
val dialogTitle = stringResource(id = android.R.string.dialog_alert_title)
|
|
||||||
val dialogContent = stringResource(id = R.string.install_inactive_slot_warning)
|
|
||||||
|
|
||||||
val onClick = { option: InstallMethod ->
|
|
||||||
currentSelectingMethod = option
|
|
||||||
when (option) {
|
|
||||||
is InstallMethod.SelectFile, is InstallMethod.HorizonKernel -> {
|
|
||||||
selectImageLauncher.launch(Intent(Intent.ACTION_GET_CONTENT).apply {
|
|
||||||
type = "application/*"
|
|
||||||
putExtra(
|
|
||||||
Intent.EXTRA_MIME_TYPES,
|
|
||||||
arrayOf("application/octet-stream", "application/zip")
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
is InstallMethod.DirectInstall -> {
|
|
||||||
selectedOption = option
|
|
||||||
onSelected(option)
|
|
||||||
}
|
|
||||||
|
|
||||||
is InstallMethod.DirectInstallToInactiveSlot -> {
|
|
||||||
confirmDialog.showConfirm(dialogTitle, dialogContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var LKMExpanded by remember { mutableStateOf(false) }
|
|
||||||
var GKIExpanded by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
// LKM 安装/修补
|
|
||||||
if (isGKI) {
|
|
||||||
ElevatedCard(
|
|
||||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = 12.dp)
|
|
||||||
.clip(MaterialTheme.shapes.large)
|
|
||||||
.shadow(
|
|
||||||
elevation = cardElevation,
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.AutoFixHigh,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
},
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.Lkm_install_methods),
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.clickable {
|
|
||||||
LKMExpanded = !LKMExpanded
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = LKMExpanded,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = shrinkVertically() + fadeOut()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = 16.dp,
|
|
||||||
end = 16.dp,
|
|
||||||
bottom = 16.dp
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
radioOptions.take(3).forEach { option ->
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
Surface(
|
|
||||||
color = if (option.javaClass == selectedOption?.javaClass)
|
|
||||||
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = cardAlpha)
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = cardAlpha),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.toggleable(
|
|
||||||
value = option.javaClass == selectedOption?.javaClass,
|
|
||||||
onValueChange = { onClick(option) },
|
|
||||||
role = Role.RadioButton,
|
|
||||||
indication = LocalIndication.current,
|
|
||||||
interactionSource = interactionSource
|
|
||||||
)
|
|
||||||
.padding(vertical = 8.dp, horizontal = 12.dp)
|
|
||||||
) {
|
|
||||||
RadioButton(
|
|
||||||
selected = option.javaClass == selectedOption?.javaClass,
|
|
||||||
onClick = null,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
colors = RadioButtonDefaults.colors(
|
|
||||||
selectedColor = MaterialTheme.colorScheme.primary,
|
|
||||||
unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(start = 10.dp)
|
|
||||||
.weight(1f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = option.label),
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
option.summary?.let {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// anykernel3 刷写
|
|
||||||
if (rootAvailable) {
|
|
||||||
ElevatedCard(
|
|
||||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = 12.dp)
|
|
||||||
.clip(MaterialTheme.shapes.large)
|
|
||||||
.shadow(
|
|
||||||
elevation = cardElevation,
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.FileUpload,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
},
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.GKI_install_methods),
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.clickable {
|
|
||||||
GKIExpanded = !GKIExpanded
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = GKIExpanded,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = shrinkVertically() + fadeOut()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = 16.dp,
|
|
||||||
end = 16.dp,
|
|
||||||
bottom = 16.dp
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
radioOptions.filterIsInstance<InstallMethod.HorizonKernel>().forEach { option ->
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
Surface(
|
|
||||||
color = if (option.javaClass == selectedOption?.javaClass)
|
|
||||||
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = cardAlpha)
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = cardAlpha),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.toggleable(
|
|
||||||
value = option.javaClass == selectedOption?.javaClass,
|
|
||||||
onValueChange = { onClick(option) },
|
|
||||||
role = Role.RadioButton,
|
|
||||||
indication = LocalIndication.current,
|
|
||||||
interactionSource = interactionSource
|
|
||||||
)
|
|
||||||
.padding(vertical = 8.dp, horizontal = 12.dp)
|
|
||||||
) {
|
|
||||||
RadioButton(
|
|
||||||
selected = option.javaClass == selectedOption?.javaClass,
|
|
||||||
onClick = null,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
colors = RadioButtonDefaults.colors(
|
|
||||||
selectedColor = MaterialTheme.colorScheme.primary,
|
|
||||||
unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(start = 10.dp)
|
|
||||||
.weight(1f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = option.label),
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
option.summary?.let {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun rememberSelectKmiDialog(onSelected: (String?) -> Unit): DialogHandle {
|
|
||||||
return rememberCustomDialog { dismiss ->
|
|
||||||
val supportedKmi by produceState(initialValue = emptyList<String>()) {
|
|
||||||
value = getSupportedKmis()
|
|
||||||
}
|
|
||||||
val options = supportedKmi.map { value ->
|
|
||||||
ListOption(
|
|
||||||
titleText = value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var selection by remember { mutableStateOf<String?>(null) }
|
|
||||||
val backgroundColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
|
||||||
|
|
||||||
MaterialTheme(
|
|
||||||
colorScheme = MaterialTheme.colorScheme.copy(
|
|
||||||
surface = backgroundColor
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
ListDialog(state = rememberUseCaseState(visible = true, onFinishedRequest = {
|
|
||||||
onSelected(selection)
|
|
||||||
}, onCloseRequest = {
|
|
||||||
dismiss()
|
|
||||||
}), header = Header.Default(
|
|
||||||
title = stringResource(R.string.select_kmi),
|
|
||||||
), selection = ListSelection.Single(
|
|
||||||
showRadioButtons = true,
|
|
||||||
options = options,
|
|
||||||
) { _, option ->
|
|
||||||
selection = option.titleText
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun TopBar(
|
|
||||||
onBack: () -> Unit = {},
|
|
||||||
onLkmUpload: () -> Unit = {},
|
|
||||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
|
||||||
) {
|
|
||||||
val cardColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
val cardAlpha = cardAlpha
|
|
||||||
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.install),
|
|
||||||
style = MaterialTheme.typography.titleLarge
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
|
||||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
|
||||||
),
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.back)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
windowInsets = WindowInsets.safeDrawing.only(
|
|
||||||
WindowInsetsSides.Top + WindowInsetsSides.Horizontal
|
|
||||||
),
|
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun SelectInstallPreview() {
|
|
||||||
InstallScreen(EmptyDestinationsNavigator)
|
|
||||||
}
|
|
||||||
@@ -1,783 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.screen
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import com.sukisu.ultra.ui.component.*
|
|
||||||
import com.sukisu.ultra.ui.theme.*
|
|
||||||
import com.sukisu.ultra.ui.viewmodel.KpmViewModel
|
|
||||||
import com.sukisu.ultra.ui.util.*
|
|
||||||
import java.io.File
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import java.io.BufferedReader
|
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.io.InputStreamReader
|
|
||||||
import java.net.*
|
|
||||||
import android.app.Activity
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig.cardElevation
|
|
||||||
|
|
||||||
/**
|
|
||||||
* KPM 管理界面
|
|
||||||
* 以下内核模块功能由KernelPatch开发,经过修改后加入SukiSU Ultra的内核模块功能
|
|
||||||
* 开发者:ShirkNeko, Liaokong
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Destination<RootGraph>
|
|
||||||
@Composable
|
|
||||||
fun KpmScreen(
|
|
||||||
navigator: DestinationsNavigator,
|
|
||||||
viewModel: KpmViewModel = viewModel()
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val snackBarHost = remember { SnackbarHostState() }
|
|
||||||
val confirmDialog = rememberConfirmDialog()
|
|
||||||
|
|
||||||
val moduleConfirmContentMap = viewModel.moduleList.associate { module ->
|
|
||||||
val moduleFileName = module.id
|
|
||||||
module.id to stringResource(R.string.confirm_uninstall_content, moduleFileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
|
||||||
|
|
||||||
val kpmInstallSuccess = stringResource(R.string.kpm_install_success)
|
|
||||||
val kpmInstallFailed = stringResource(R.string.kpm_install_failed)
|
|
||||||
val cancel = stringResource(R.string.cancel)
|
|
||||||
val uninstall = stringResource(R.string.uninstall)
|
|
||||||
val failedToCheckModuleFile = stringResource(R.string.snackbar_failed_to_check_module_file)
|
|
||||||
val kpmUninstallSuccess = stringResource(R.string.kpm_uninstall_success)
|
|
||||||
val kpmUninstallFailed = stringResource(R.string.kpm_uninstall_failed)
|
|
||||||
val kpmInstallMode = stringResource(R.string.kpm_install_mode)
|
|
||||||
val kpmInstallModeLoad = stringResource(R.string.kpm_install_mode_load)
|
|
||||||
val kpmInstallModeEmbed = stringResource(R.string.kpm_install_mode_embed)
|
|
||||||
val invalidFileTypeMessage = stringResource(R.string.invalid_file_type)
|
|
||||||
val confirmTitle = stringResource(R.string.confirm_uninstall_title_with_filename)
|
|
||||||
|
|
||||||
var tempFileForInstall by remember { mutableStateOf<File?>(null) }
|
|
||||||
val installModeDialog = rememberCustomDialog { dismiss ->
|
|
||||||
var moduleName by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(tempFileForInstall) {
|
|
||||||
tempFileForInstall?.let { tempFile ->
|
|
||||||
try {
|
|
||||||
val command = arrayOf("su", "-c", "strings ${tempFile.absolutePath} | grep 'name='")
|
|
||||||
val process = Runtime.getRuntime().exec(command)
|
|
||||||
val inputStream = process.inputStream
|
|
||||||
val reader = BufferedReader(InputStreamReader(inputStream))
|
|
||||||
var line: String?
|
|
||||||
while (reader.readLine().also { line = it } != null) {
|
|
||||||
if (line!!.startsWith("name=")) {
|
|
||||||
moduleName = line.substringAfter("name=").trim()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
process.waitFor()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("KsuCli", "Failed to get module name: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = {
|
|
||||||
dismiss()
|
|
||||||
tempFileForInstall?.delete()
|
|
||||||
tempFileForInstall = null
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = kpmInstallMode,
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
moduleName?.let {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.kpm_install_mode_description, it),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
dismiss()
|
|
||||||
tempFileForInstall?.let { tempFile ->
|
|
||||||
handleModuleInstall(
|
|
||||||
tempFile = tempFile,
|
|
||||||
isEmbed = false,
|
|
||||||
viewModel = viewModel,
|
|
||||||
snackBarHost = snackBarHost,
|
|
||||||
kpmInstallSuccess = kpmInstallSuccess,
|
|
||||||
kpmInstallFailed = kpmInstallFailed
|
|
||||||
)
|
|
||||||
}
|
|
||||||
tempFileForInstall = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Download,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp).padding(end = 4.dp)
|
|
||||||
)
|
|
||||||
Text(kpmInstallModeLoad)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
dismiss()
|
|
||||||
tempFileForInstall?.let { tempFile ->
|
|
||||||
handleModuleInstall(
|
|
||||||
tempFile = tempFile,
|
|
||||||
isEmbed = true,
|
|
||||||
viewModel = viewModel,
|
|
||||||
snackBarHost = snackBarHost,
|
|
||||||
kpmInstallSuccess = kpmInstallSuccess,
|
|
||||||
kpmInstallFailed = kpmInstallFailed
|
|
||||||
)
|
|
||||||
}
|
|
||||||
tempFileForInstall = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.secondary
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Inventory,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp).padding(end = 4.dp)
|
|
||||||
)
|
|
||||||
Text(kpmInstallModeEmbed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
dismiss()
|
|
||||||
tempFileForInstall?.delete()
|
|
||||||
tempFileForInstall = null
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(cancel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
shape = MaterialTheme.shapes.extraLarge
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val selectPatchLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
|
||||||
) { result ->
|
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
|
||||||
|
|
||||||
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
val fileName = uri.lastPathSegment ?: "unknown.kpm"
|
|
||||||
val encodedFileName = URLEncoder.encode(fileName, "UTF-8")
|
|
||||||
val tempFile = File(context.cacheDir, encodedFileName)
|
|
||||||
|
|
||||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
|
||||||
tempFile.outputStream().use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val mimeType = context.contentResolver.getType(uri)
|
|
||||||
val isCorrectMimeType = mimeType == null || mimeType.contains("application/octet-stream")
|
|
||||||
|
|
||||||
if (!isCorrectMimeType) {
|
|
||||||
var shouldShowSnackbar = true
|
|
||||||
try {
|
|
||||||
val matchCount = checkStringsCommand(tempFile)
|
|
||||||
val isElf = isElfFile(tempFile)
|
|
||||||
|
|
||||||
if (matchCount >= 1 || isElf) {
|
|
||||||
shouldShowSnackbar = false
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("KsuCli", "Failed to execute checks: ${e.message}", e)
|
|
||||||
}
|
|
||||||
if (shouldShowSnackbar) {
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
message = invalidFileTypeMessage,
|
|
||||||
duration = SnackbarDuration.Short
|
|
||||||
)
|
|
||||||
}
|
|
||||||
tempFile.delete()
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
tempFileForInstall = tempFile
|
|
||||||
installModeDialog.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
while(true) {
|
|
||||||
viewModel.fetchModuleList()
|
|
||||||
delay(5000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val sharedPreferences = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE)
|
|
||||||
var isNoticeClosed by remember { mutableStateOf(sharedPreferences.getBoolean("is_notice_closed", false)) }
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
SearchAppBar(
|
|
||||||
title = { Text(stringResource(R.string.kpm_title)) },
|
|
||||||
searchText = viewModel.search,
|
|
||||||
onSearchTextChange = { viewModel.search = it },
|
|
||||||
onClearClick = { viewModel.search = "" },
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
dropdownContent = {
|
|
||||||
IconButton(
|
|
||||||
onClick = { viewModel.fetchModuleList() }
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Refresh,
|
|
||||||
contentDescription = stringResource(R.string.refresh),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
floatingActionButton = {
|
|
||||||
ExtendedFloatingActionButton(
|
|
||||||
onClick = {
|
|
||||||
selectPatchLauncher.launch(
|
|
||||||
Intent(Intent.ACTION_GET_CONTENT).apply {
|
|
||||||
type = "application/octet-stream"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Add,
|
|
||||||
contentDescription = stringResource(R.string.kpm_install),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.kpm_install),
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
},
|
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
expanded = true,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = { SnackbarHost(snackBarHost) }
|
|
||||||
) { padding ->
|
|
||||||
Column(modifier = Modifier.padding(padding)) {
|
|
||||||
if (!isNoticeClosed) {
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Info,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(end = 16.dp)
|
|
||||||
.size(24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.kernel_module_notice),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
isNoticeClosed = true
|
|
||||||
sharedPreferences.edit { putBoolean("is_notice_closed", true) }
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
colors = IconButtonDefaults.iconButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Close,
|
|
||||||
contentDescription = stringResource(R.string.close_notice)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viewModel.moduleList.isEmpty()) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Code,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f),
|
|
||||||
modifier = Modifier
|
|
||||||
.size(96.dp)
|
|
||||||
.padding(bottom = 16.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.kpm_empty),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
items(viewModel.moduleList) { module ->
|
|
||||||
KpmModuleItem(
|
|
||||||
module = module,
|
|
||||||
onUninstall = {
|
|
||||||
scope.launch {
|
|
||||||
val confirmContent = moduleConfirmContentMap[module.id] ?: ""
|
|
||||||
handleModuleUninstall(
|
|
||||||
module = module,
|
|
||||||
viewModel = viewModel,
|
|
||||||
snackBarHost = snackBarHost,
|
|
||||||
kpmUninstallSuccess = kpmUninstallSuccess,
|
|
||||||
kpmUninstallFailed = kpmUninstallFailed,
|
|
||||||
failedToCheckModuleFile = failedToCheckModuleFile,
|
|
||||||
uninstall = uninstall,
|
|
||||||
cancel = cancel,
|
|
||||||
confirmDialog = confirmDialog,
|
|
||||||
confirmTitle = confirmTitle,
|
|
||||||
confirmContent = confirmContent
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onControl = {
|
|
||||||
viewModel.loadModuleDetail(module.id)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun handleModuleInstall(
|
|
||||||
tempFile: File,
|
|
||||||
isEmbed: Boolean,
|
|
||||||
viewModel: KpmViewModel,
|
|
||||||
snackBarHost: SnackbarHostState,
|
|
||||||
kpmInstallSuccess: String,
|
|
||||||
kpmInstallFailed: String
|
|
||||||
) {
|
|
||||||
var moduleId: String? = null
|
|
||||||
try {
|
|
||||||
val command = arrayOf("su", "-c", "strings ${tempFile.absolutePath} | grep 'name='")
|
|
||||||
val process = Runtime.getRuntime().exec(command)
|
|
||||||
val inputStream = process.inputStream
|
|
||||||
val reader = BufferedReader(InputStreamReader(inputStream))
|
|
||||||
var line: String?
|
|
||||||
while (reader.readLine().also { line = it } != null) {
|
|
||||||
if (line!!.startsWith("name=")) {
|
|
||||||
moduleId = line.substringAfter("name=").trim()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
process.waitFor()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("KsuCli", "Failed to get module ID from strings command: ${e.message}", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (moduleId == null || moduleId.isEmpty()) {
|
|
||||||
Log.e("KsuCli", "Failed to extract module ID from file: ${tempFile.name}")
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
message = kpmInstallFailed,
|
|
||||||
duration = SnackbarDuration.Short
|
|
||||||
)
|
|
||||||
tempFile.delete()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val targetPath = "/data/adb/kpm/$moduleId.kpm"
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isEmbed) {
|
|
||||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "mkdir -p /data/adb/kpm")).waitFor()
|
|
||||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "cp ${tempFile.absolutePath} $targetPath")).waitFor()
|
|
||||||
}
|
|
||||||
|
|
||||||
val loadResult = loadKpmModule(tempFile.absolutePath)
|
|
||||||
if (loadResult.startsWith("Error")) {
|
|
||||||
Log.e("KsuCli", "Failed to load KPM module: $loadResult")
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
message = kpmInstallFailed,
|
|
||||||
duration = SnackbarDuration.Short
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
viewModel.fetchModuleList()
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
message = kpmInstallSuccess,
|
|
||||||
duration = SnackbarDuration.Short
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("KsuCli", "Failed to load KPM module: ${e.message}", e)
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
message = kpmInstallFailed,
|
|
||||||
duration = SnackbarDuration.Short
|
|
||||||
)
|
|
||||||
}
|
|
||||||
tempFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun handleModuleUninstall(
|
|
||||||
module: KpmViewModel.ModuleInfo,
|
|
||||||
viewModel: KpmViewModel,
|
|
||||||
snackBarHost: SnackbarHostState,
|
|
||||||
kpmUninstallSuccess: String,
|
|
||||||
kpmUninstallFailed: String,
|
|
||||||
failedToCheckModuleFile: String,
|
|
||||||
uninstall: String,
|
|
||||||
cancel: String,
|
|
||||||
confirmTitle : String,
|
|
||||||
confirmContent : String,
|
|
||||||
confirmDialog: ConfirmDialogHandle
|
|
||||||
) {
|
|
||||||
val moduleFileName = "${module.id}.kpm"
|
|
||||||
val moduleFilePath = "/data/adb/kpm/$moduleFileName"
|
|
||||||
|
|
||||||
val fileExists = try {
|
|
||||||
val result = Runtime.getRuntime().exec(arrayOf("su", "-c", "ls /data/adb/kpm/$moduleFileName")).waitFor() == 0
|
|
||||||
result
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("KsuCli", "Failed to check module file existence: ${e.message}", e)
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
message = failedToCheckModuleFile,
|
|
||||||
duration = SnackbarDuration.Short
|
|
||||||
)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
val confirmResult = confirmDialog.awaitConfirm(
|
|
||||||
title = confirmTitle,
|
|
||||||
content = confirmContent,
|
|
||||||
confirm = uninstall,
|
|
||||||
dismiss = cancel
|
|
||||||
)
|
|
||||||
|
|
||||||
if (confirmResult == ConfirmResult.Confirmed) {
|
|
||||||
try {
|
|
||||||
val unloadResult = unloadKpmModule(module.id)
|
|
||||||
if (unloadResult.startsWith("Error")) {
|
|
||||||
Log.e("KsuCli", "Failed to unload KPM module: $unloadResult")
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
message = kpmUninstallFailed,
|
|
||||||
duration = SnackbarDuration.Short
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileExists) {
|
|
||||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "rm $moduleFilePath")).waitFor()
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.fetchModuleList()
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
message = kpmUninstallSuccess,
|
|
||||||
duration = SnackbarDuration.Short
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("KsuCli", "Failed to unload KPM module: ${e.message}", e)
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
message = kpmUninstallFailed,
|
|
||||||
duration = SnackbarDuration.Short
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun KpmModuleItem(
|
|
||||||
module: KpmViewModel.ModuleInfo,
|
|
||||||
onUninstall: () -> Unit,
|
|
||||||
onControl: () -> Unit
|
|
||||||
) {
|
|
||||||
val viewModel: KpmViewModel = viewModel()
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val snackBarHost = remember { SnackbarHostState() }
|
|
||||||
val successMessage = stringResource(R.string.kpm_control_success)
|
|
||||||
val failureMessage = stringResource(R.string.kpm_control_failed)
|
|
||||||
|
|
||||||
if (viewModel.showInputDialog && viewModel.selectedModuleId == module.id) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { viewModel.hideInputDialog() },
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.kpm_control),
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = viewModel.inputArgs,
|
|
||||||
onValueChange = { viewModel.updateInputArgs(it) },
|
|
||||||
label = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.kpm_args),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
},
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
text = module.args,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
|
||||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
|
||||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
val result = viewModel.executeControl()
|
|
||||||
val message = when (result) {
|
|
||||||
0 -> successMessage
|
|
||||||
else -> failureMessage
|
|
||||||
}
|
|
||||||
snackBarHost.showSnackbar(message)
|
|
||||||
onControl()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.confirm),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { viewModel.hideInputDialog() }) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.cancel),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
shape = MaterialTheme.shapes.extraLarge
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(
|
|
||||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerHigh),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(MaterialTheme.shapes.large)
|
|
||||||
.shadow(
|
|
||||||
elevation = cardElevation,
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(20.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = module.name,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "${stringResource(R.string.kpm_version)}: ${module.version}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "${stringResource(R.string.kpm_author)}: ${module.author}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "${stringResource(R.string.kpm_args)}: ${module.args}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = module.description,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.showInputDialog(module.id) },
|
|
||||||
enabled = module.hasAction,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
|
||||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Settings,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(stringResource(R.string.kpm_control))
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = onUninstall,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Delete,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(stringResource(R.string.kpm_uninstall))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkStringsCommand(tempFile: File): Int {
|
|
||||||
val command = arrayOf("su", "-c", "strings ${tempFile.absolutePath} | grep -E 'name=|version=|license=|author='")
|
|
||||||
val process = Runtime.getRuntime().exec(command)
|
|
||||||
val inputStream = process.inputStream
|
|
||||||
val reader = BufferedReader(InputStreamReader(inputStream))
|
|
||||||
var line: String?
|
|
||||||
var matchCount = 0
|
|
||||||
val keywords = listOf("name=", "version=", "license=", "author=")
|
|
||||||
var nameExists = false
|
|
||||||
|
|
||||||
while (reader.readLine().also { line = it } != null) {
|
|
||||||
if (!nameExists && line!!.startsWith("name=")) {
|
|
||||||
nameExists = true
|
|
||||||
matchCount++
|
|
||||||
} else if (nameExists) {
|
|
||||||
for (keyword in keywords) {
|
|
||||||
if (line!!.startsWith(keyword)) {
|
|
||||||
matchCount++
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
process.waitFor()
|
|
||||||
|
|
||||||
return if (nameExists) matchCount else 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isElfFile(tempFile: File): Boolean {
|
|
||||||
val elfMagic = byteArrayOf(0x7F, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte())
|
|
||||||
val fileBytes = ByteArray(4)
|
|
||||||
FileInputStream(tempFile).use { input ->
|
|
||||||
input.read(fileBytes)
|
|
||||||
}
|
|
||||||
return fileBytes.contentEquals(elfMagic)
|
|
||||||
}
|
|
||||||
@@ -1,937 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.screen
|
|
||||||
|
|
||||||
import android.app.Activity.*
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.*
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.selection.toggleable
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.outlined.*
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.platform.*
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.ExecuteModuleActionScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
|
||||||
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import com.sukisu.ultra.Natives
|
|
||||||
import com.sukisu.ultra.ui.component.ConfirmResult
|
|
||||||
import com.sukisu.ultra.ui.component.SearchAppBar
|
|
||||||
import com.sukisu.ultra.ui.component.rememberConfirmDialog
|
|
||||||
import com.sukisu.ultra.ui.component.rememberLoadingDialog
|
|
||||||
import com.sukisu.ultra.ui.util.DownloadListener
|
|
||||||
import com.sukisu.ultra.ui.util.*
|
|
||||||
import com.sukisu.ultra.ui.util.download
|
|
||||||
import com.sukisu.ultra.ui.util.hasMagisk
|
|
||||||
import com.sukisu.ultra.ui.util.reboot
|
|
||||||
import com.sukisu.ultra.ui.util.restoreModule
|
|
||||||
import com.sukisu.ultra.ui.util.toggleModule
|
|
||||||
import com.sukisu.ultra.ui.util.uninstallModule
|
|
||||||
import com.sukisu.ultra.ui.webui.WebUIActivity
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import com.sukisu.ultra.ui.util.ModuleModify
|
|
||||||
import com.sukisu.ultra.ui.theme.getCardColors
|
|
||||||
import com.sukisu.ultra.ui.viewmodel.ModuleViewModel
|
|
||||||
import java.io.BufferedReader
|
|
||||||
import java.io.InputStreamReader
|
|
||||||
import java.util.zip.ZipInputStream
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import com.sukisu.ultra.ui.theme.ThemeConfig
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig.cardElevation
|
|
||||||
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Destination<RootGraph>
|
|
||||||
@Composable
|
|
||||||
fun ModuleScreen(navigator: DestinationsNavigator) {
|
|
||||||
val viewModel = viewModel<ModuleViewModel>()
|
|
||||||
val context = LocalContext.current
|
|
||||||
val snackBarHost = LocalSnackbarHost.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val confirmDialog = rememberConfirmDialog()
|
|
||||||
|
|
||||||
val selectZipLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
|
||||||
) {
|
|
||||||
if (it.resultCode != RESULT_OK) {
|
|
||||||
return@rememberLauncherForActivityResult
|
|
||||||
}
|
|
||||||
val data = it.data ?: return@rememberLauncherForActivityResult
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
val clipData = data.clipData
|
|
||||||
if (clipData != null) {
|
|
||||||
// 处理多选结果
|
|
||||||
val selectedModules = mutableSetOf<Uri>()
|
|
||||||
val selectedModuleNames = mutableMapOf<Uri, String>()
|
|
||||||
|
|
||||||
suspend fun processUri(uri: Uri) {
|
|
||||||
val moduleName = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val zipInputStream = ZipInputStream(context.contentResolver.openInputStream(uri))
|
|
||||||
var entry = zipInputStream.nextEntry
|
|
||||||
var name = context.getString(R.string.unknown_module)
|
|
||||||
|
|
||||||
while (entry != null) {
|
|
||||||
if (entry.name == "module.prop") {
|
|
||||||
val reader = BufferedReader(InputStreamReader(zipInputStream))
|
|
||||||
var line: String?
|
|
||||||
while (reader.readLine().also { line = it } != null) {
|
|
||||||
if (line?.startsWith("name=") == true) {
|
|
||||||
name = line.substringAfter("=")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
entry = zipInputStream.nextEntry
|
|
||||||
}
|
|
||||||
name
|
|
||||||
} catch (e: Exception) {
|
|
||||||
context.getString(R.string.unknown_module)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
selectedModules.add(uri)
|
|
||||||
selectedModuleNames[uri] = moduleName
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i in 0 until clipData.itemCount) {
|
|
||||||
val uri = clipData.getItemAt(i).uri
|
|
||||||
processUri(uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示确认对话框
|
|
||||||
val modulesList = selectedModuleNames.values.joinToString("\n• ", "• ")
|
|
||||||
val confirmResult = confirmDialog.awaitConfirm(
|
|
||||||
title = context.getString(R.string.module_install),
|
|
||||||
content = context.getString(R.string.module_install_multiple_confirm_with_names, selectedModules.size, modulesList),
|
|
||||||
confirm = context.getString(R.string.install),
|
|
||||||
dismiss = context.getString(R.string.cancel)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (confirmResult == ConfirmResult.Confirmed) {
|
|
||||||
// 批量安装模块
|
|
||||||
selectedModules.forEach { uri ->
|
|
||||||
navigator.navigate(FlashScreenDestination(FlashIt.FlashModule(uri)))
|
|
||||||
}
|
|
||||||
viewModel.markNeedRefresh()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 单个文件安装逻辑
|
|
||||||
val uri = data.data ?: return@launch
|
|
||||||
val moduleName = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val zipInputStream = ZipInputStream(context.contentResolver.openInputStream(uri))
|
|
||||||
var entry = zipInputStream.nextEntry
|
|
||||||
var name = context.getString(R.string.unknown_module)
|
|
||||||
|
|
||||||
while (entry != null) {
|
|
||||||
if (entry.name == "module.prop") {
|
|
||||||
val reader = BufferedReader(InputStreamReader(zipInputStream))
|
|
||||||
var line: String?
|
|
||||||
while (reader.readLine().also { line = it } != null) {
|
|
||||||
if (line?.startsWith("name=") == true) {
|
|
||||||
name = line.substringAfter("=")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
entry = zipInputStream.nextEntry
|
|
||||||
}
|
|
||||||
name
|
|
||||||
} catch (e: Exception) {
|
|
||||||
context.getString(R.string.unknown_module)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val confirmResult = confirmDialog.awaitConfirm(
|
|
||||||
title = context.getString(R.string.module_install),
|
|
||||||
content = context.getString(R.string.module_install_confirm, moduleName),
|
|
||||||
confirm = context.getString(R.string.install),
|
|
||||||
dismiss = context.getString(R.string.cancel)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (confirmResult == ConfirmResult.Confirmed) {
|
|
||||||
navigator.navigate(FlashScreenDestination(FlashIt.FlashModule(uri)))
|
|
||||||
viewModel.markNeedRefresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val backupLauncher = ModuleModify.rememberModuleBackupLauncher(context, snackBarHost)
|
|
||||||
val restoreLauncher = ModuleModify.rememberModuleRestoreLauncher(context, snackBarHost)
|
|
||||||
|
|
||||||
val prefs = context.getSharedPreferences("settings", MODE_PRIVATE)
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
if (viewModel.moduleList.isEmpty() || viewModel.isNeedRefresh) {
|
|
||||||
viewModel.sortEnabledFirst = prefs.getBoolean("module_sort_enabled_first", false)
|
|
||||||
viewModel.sortActionFirst = prefs.getBoolean("module_sort_action_first", false)
|
|
||||||
viewModel.fetchModuleList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val isSafeMode = Natives.isSafeMode
|
|
||||||
val hasMagisk = hasMagisk()
|
|
||||||
|
|
||||||
val hideInstallButton = isSafeMode || hasMagisk
|
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
|
||||||
|
|
||||||
val webUILauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
|
||||||
) { viewModel.fetchModuleList() }
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
SearchAppBar(
|
|
||||||
title = { Text(stringResource(R.string.module)) },
|
|
||||||
searchText = viewModel.search,
|
|
||||||
onSearchTextChange = { viewModel.search = it },
|
|
||||||
onClearClick = { viewModel.search = "" },
|
|
||||||
dropdownContent = {
|
|
||||||
var showDropdown by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = { showDropdown = true },
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.MoreVert,
|
|
||||||
contentDescription = stringResource(id = R.string.settings),
|
|
||||||
)
|
|
||||||
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = showDropdown,
|
|
||||||
onDismissRequest = { showDropdown = false }
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.module_sort_action_first)) },
|
|
||||||
trailingIcon = {
|
|
||||||
Checkbox(
|
|
||||||
checked = viewModel.sortActionFirst,
|
|
||||||
onCheckedChange = null,
|
|
||||||
colors = CheckboxDefaults.colors(
|
|
||||||
checkedColor = MaterialTheme.colorScheme.primary,
|
|
||||||
uncheckedColor = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
viewModel.sortActionFirst = !viewModel.sortActionFirst
|
|
||||||
prefs.edit {
|
|
||||||
putBoolean(
|
|
||||||
"module_sort_action_first",
|
|
||||||
viewModel.sortActionFirst
|
|
||||||
)
|
|
||||||
}
|
|
||||||
scope.launch {
|
|
||||||
viewModel.fetchModuleList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.module_sort_enabled_first)) },
|
|
||||||
trailingIcon = {
|
|
||||||
Checkbox(
|
|
||||||
checked = viewModel.sortEnabledFirst,
|
|
||||||
onCheckedChange = null,
|
|
||||||
colors = CheckboxDefaults.colors(
|
|
||||||
checkedColor = MaterialTheme.colorScheme.primary,
|
|
||||||
uncheckedColor = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
viewModel.sortEnabledFirst = !viewModel.sortEnabledFirst
|
|
||||||
prefs.edit {
|
|
||||||
putBoolean("module_sort_enabled_first", viewModel.sortEnabledFirst)
|
|
||||||
}
|
|
||||||
scope.launch {
|
|
||||||
viewModel.fetchModuleList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
HorizontalDivider(thickness = Dp.Hairline, modifier = Modifier.padding(vertical = 4.dp))
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.backup_modules)) },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Download,
|
|
||||||
contentDescription = stringResource(R.string.backup),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
showDropdown = false
|
|
||||||
backupLauncher.launch(ModuleModify.createBackupIntent())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.restore_modules)) },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Refresh,
|
|
||||||
contentDescription = stringResource(R.string.restore),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
showDropdown = false
|
|
||||||
restoreLauncher.launch(ModuleModify.createRestoreIntent())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
floatingActionButton = {
|
|
||||||
if (!hideInstallButton) {
|
|
||||||
val moduleInstall = stringResource(id = R.string.module_install)
|
|
||||||
ExtendedFloatingActionButton(
|
|
||||||
onClick = {
|
|
||||||
selectZipLauncher.launch(
|
|
||||||
Intent(Intent.ACTION_GET_CONTENT).apply {
|
|
||||||
type = "application/zip"
|
|
||||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Add,
|
|
||||||
contentDescription = moduleInstall,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
text = moduleInstall,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
},
|
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
expanded = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentWindowInsets = WindowInsets.safeDrawing.only(
|
|
||||||
WindowInsetsSides.Top + WindowInsetsSides.Horizontal
|
|
||||||
),
|
|
||||||
snackbarHost = { SnackbarHost(hostState = snackBarHost) }
|
|
||||||
) { innerPadding ->
|
|
||||||
when {
|
|
||||||
hasMagisk -> {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Warning,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(64.dp)
|
|
||||||
.padding(bottom = 16.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.module_magisk_conflict),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
ModuleList(
|
|
||||||
navigator = navigator,
|
|
||||||
viewModel = viewModel,
|
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
|
||||||
boxModifier = Modifier.padding(innerPadding),
|
|
||||||
onInstallModule = {
|
|
||||||
navigator.navigate(FlashScreenDestination(FlashIt.FlashModule(it)))
|
|
||||||
},
|
|
||||||
onClickModule = { id, name, hasWebUi ->
|
|
||||||
if (hasWebUi) {
|
|
||||||
webUILauncher.launch(
|
|
||||||
Intent(context, WebUIActivity::class.java)
|
|
||||||
.setData("kernelsu://webui/$id".toUri())
|
|
||||||
.putExtra("id", id)
|
|
||||||
.putExtra("name", name)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
context = context,
|
|
||||||
snackBarHost = snackBarHost
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun ModuleList(
|
|
||||||
navigator: DestinationsNavigator,
|
|
||||||
viewModel: ModuleViewModel,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
boxModifier: Modifier = Modifier,
|
|
||||||
onInstallModule: (Uri) -> Unit,
|
|
||||||
onClickModule: (id: String, name: String, hasWebUi: Boolean) -> Unit,
|
|
||||||
context: Context,
|
|
||||||
snackBarHost: SnackbarHostState
|
|
||||||
) {
|
|
||||||
val failedEnable = stringResource(R.string.module_failed_to_enable)
|
|
||||||
val failedDisable = stringResource(R.string.module_failed_to_disable)
|
|
||||||
val failedUninstall = stringResource(R.string.module_uninstall_failed)
|
|
||||||
val successUninstall = stringResource(R.string.module_uninstall_success)
|
|
||||||
val reboot = stringResource(R.string.reboot)
|
|
||||||
val rebootToApply = stringResource(R.string.reboot_to_apply)
|
|
||||||
val moduleStr = stringResource(R.string.module)
|
|
||||||
val uninstall = stringResource(R.string.uninstall)
|
|
||||||
val cancel = stringResource(android.R.string.cancel)
|
|
||||||
val moduleUninstallConfirm = stringResource(R.string.module_uninstall_confirm)
|
|
||||||
val updateText = stringResource(R.string.module_update)
|
|
||||||
val changelogText = stringResource(R.string.module_changelog)
|
|
||||||
val downloadingText = stringResource(R.string.module_downloading)
|
|
||||||
val startDownloadingText = stringResource(R.string.module_start_downloading)
|
|
||||||
val fetchChangeLogFailed = stringResource(R.string.module_changelog_failed)
|
|
||||||
|
|
||||||
val loadingDialog = rememberLoadingDialog()
|
|
||||||
val confirmDialog = rememberConfirmDialog()
|
|
||||||
|
|
||||||
suspend fun onModuleUpdate(
|
|
||||||
module: ModuleViewModel.ModuleInfo,
|
|
||||||
changelogUrl: String,
|
|
||||||
downloadUrl: String,
|
|
||||||
fileName: String
|
|
||||||
) {
|
|
||||||
val changelogResult = loadingDialog.withLoading {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
runCatching {
|
|
||||||
OkHttpClient().newCall(
|
|
||||||
okhttp3.Request.Builder().url(changelogUrl).build()
|
|
||||||
).execute().body!!.string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val showToast: suspend (String) -> Unit = { msg ->
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
msg,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val changelog = changelogResult.getOrElse {
|
|
||||||
showToast(fetchChangeLogFailed.format(it.message))
|
|
||||||
return
|
|
||||||
}.ifBlank {
|
|
||||||
showToast(fetchChangeLogFailed.format(module.name))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// changelog is not empty, show it and wait for confirm
|
|
||||||
val confirmResult = confirmDialog.awaitConfirm(
|
|
||||||
changelogText,
|
|
||||||
content = changelog,
|
|
||||||
markdown = true,
|
|
||||||
confirm = updateText,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (confirmResult != ConfirmResult.Confirmed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast(startDownloadingText.format(module.name))
|
|
||||||
|
|
||||||
val downloading = downloadingText.format(module.name)
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
download(
|
|
||||||
context,
|
|
||||||
downloadUrl,
|
|
||||||
fileName,
|
|
||||||
downloading,
|
|
||||||
onDownloaded = onInstallModule,
|
|
||||||
onDownloading = {
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
Toast.makeText(context, downloading, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun onModuleUninstallClicked(module: ModuleViewModel.ModuleInfo) {
|
|
||||||
val isUninstall = !module.remove
|
|
||||||
if (isUninstall) {
|
|
||||||
val confirmResult = confirmDialog.awaitConfirm(
|
|
||||||
moduleStr,
|
|
||||||
content = moduleUninstallConfirm.format(module.name),
|
|
||||||
confirm = uninstall,
|
|
||||||
dismiss = cancel
|
|
||||||
)
|
|
||||||
if (confirmResult != ConfirmResult.Confirmed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val success = loadingDialog.withLoading {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
if (isUninstall) {
|
|
||||||
uninstallModule(module.dirId)
|
|
||||||
} else {
|
|
||||||
restoreModule(module.dirId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
viewModel.fetchModuleList()
|
|
||||||
}
|
|
||||||
if (!isUninstall) return
|
|
||||||
val message = if (success) {
|
|
||||||
successUninstall.format(module.name)
|
|
||||||
} else {
|
|
||||||
failedUninstall.format(module.name)
|
|
||||||
}
|
|
||||||
val actionLabel = if (success) {
|
|
||||||
reboot
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val result = snackBarHost.showSnackbar(
|
|
||||||
message = message,
|
|
||||||
actionLabel = actionLabel,
|
|
||||||
duration = SnackbarDuration.Long
|
|
||||||
)
|
|
||||||
if (result == SnackbarResult.ActionPerformed) {
|
|
||||||
reboot()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PullToRefreshBox(
|
|
||||||
modifier = boxModifier,
|
|
||||||
onRefresh = {
|
|
||||||
viewModel.fetchModuleList()
|
|
||||||
},
|
|
||||||
isRefreshing = viewModel.isRefreshing
|
|
||||||
) {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = modifier,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
contentPadding = remember {
|
|
||||||
PaddingValues(
|
|
||||||
start = 16.dp,
|
|
||||||
top = 16.dp,
|
|
||||||
end = 16.dp,
|
|
||||||
bottom = 16.dp + 56.dp + 16.dp + 48.dp + 6.dp /* Scaffold Fab Spacing + Fab container height + SnackBar height */
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
when {
|
|
||||||
viewModel.moduleList.isEmpty() -> {
|
|
||||||
item {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillParentMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Extension,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f),
|
|
||||||
modifier = Modifier
|
|
||||||
.size(96.dp)
|
|
||||||
.padding(bottom = 16.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.module_empty),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
items(viewModel.moduleList) { module ->
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val updatedModule by produceState(initialValue = Triple("", "", "")) {
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
value = viewModel.checkUpdate(module)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ModuleItem(
|
|
||||||
navigator = navigator,
|
|
||||||
module = module,
|
|
||||||
updateUrl = updatedModule.first,
|
|
||||||
onUninstallClicked = {
|
|
||||||
scope.launch { onModuleUninstallClicked(module) }
|
|
||||||
},
|
|
||||||
onCheckChanged = {
|
|
||||||
scope.launch {
|
|
||||||
val success = loadingDialog.withLoading {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
toggleModule(module.dirId, !module.enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (success) {
|
|
||||||
viewModel.fetchModuleList()
|
|
||||||
|
|
||||||
val result = snackBarHost.showSnackbar(
|
|
||||||
message = rebootToApply,
|
|
||||||
actionLabel = reboot,
|
|
||||||
duration = SnackbarDuration.Long
|
|
||||||
)
|
|
||||||
if (result == SnackbarResult.ActionPerformed) {
|
|
||||||
reboot()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val message = if (module.enabled) failedDisable else failedEnable
|
|
||||||
snackBarHost.showSnackbar(message.format(module.name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onUpdate = {
|
|
||||||
scope.launch {
|
|
||||||
onModuleUpdate(
|
|
||||||
module,
|
|
||||||
updatedModule.third,
|
|
||||||
updatedModule.first,
|
|
||||||
"${module.name}-${updatedModule.second}.zip"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
onClickModule(it.dirId, it.name, it.hasWebUi)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// fix last item shadow incomplete in LazyColumn
|
|
||||||
Spacer(Modifier.height(1.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DownloadListener(context, onInstallModule)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ModuleItem(
|
|
||||||
navigator: DestinationsNavigator,
|
|
||||||
module: ModuleViewModel.ModuleInfo,
|
|
||||||
updateUrl: String,
|
|
||||||
onUninstallClicked: (ModuleViewModel.ModuleInfo) -> Unit,
|
|
||||||
onCheckChanged: (Boolean) -> Unit,
|
|
||||||
onUpdate: (ModuleViewModel.ModuleInfo) -> Unit,
|
|
||||||
onClick: (ModuleViewModel.ModuleInfo) -> Unit
|
|
||||||
) {
|
|
||||||
ElevatedCard(
|
|
||||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerHigh),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(MaterialTheme.shapes.large)
|
|
||||||
.shadow(
|
|
||||||
elevation = cardElevation,
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
val textDecoration = if (!module.remove) null else TextDecoration.LineThrough
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val indication = LocalIndication.current
|
|
||||||
val viewModel = viewModel<ModuleViewModel>()
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.run {
|
|
||||||
if (module.hasWebUi) {
|
|
||||||
toggleable(
|
|
||||||
value = module.enabled,
|
|
||||||
enabled = !module.remove && module.enabled,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
role = Role.Button,
|
|
||||||
indication = indication,
|
|
||||||
onValueChange = { onClick(module) }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(22.dp, 18.dp, 22.dp, 12.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
val moduleVersion = stringResource(id = R.string.module_version)
|
|
||||||
val moduleAuthor = stringResource(id = R.string.module_author)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(0.8f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = module.name,
|
|
||||||
fontSize = MaterialTheme.typography.titleMedium.fontSize,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
|
||||||
fontFamily = MaterialTheme.typography.titleMedium.fontFamily,
|
|
||||||
textDecoration = textDecoration,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "$moduleVersion: ${module.version}",
|
|
||||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
|
||||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
|
||||||
fontFamily = MaterialTheme.typography.bodySmall.fontFamily,
|
|
||||||
textDecoration = textDecoration,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "$moduleAuthor: ${module.author}",
|
|
||||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
|
||||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
|
||||||
fontFamily = MaterialTheme.typography.bodySmall.fontFamily,
|
|
||||||
textDecoration = textDecoration,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
) {
|
|
||||||
Switch(
|
|
||||||
enabled = !module.update,
|
|
||||||
checked = module.enabled,
|
|
||||||
onCheckedChange = onCheckChanged,
|
|
||||||
interactionSource = if (!module.hasWebUi) interactionSource else null,
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
checkedTrackColor = MaterialTheme.colorScheme.primary,
|
|
||||||
checkedIconColor = MaterialTheme.colorScheme.primary,
|
|
||||||
uncheckedThumbColor = MaterialTheme.colorScheme.outline,
|
|
||||||
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
uncheckedIconColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = module.description,
|
|
||||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
|
||||||
fontFamily = MaterialTheme.typography.bodySmall.fontFamily,
|
|
||||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
|
||||||
fontWeight = MaterialTheme.typography.bodySmall.fontWeight,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
maxLines = 4,
|
|
||||||
textDecoration = textDecoration,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
HorizontalDivider(thickness = Dp.Hairline)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
if (module.hasActionScript) {
|
|
||||||
FilledTonalButton(
|
|
||||||
modifier = Modifier.defaultMinSize(minWidth = 52.dp, minHeight = 32.dp),
|
|
||||||
enabled = !module.remove && module.enabled,
|
|
||||||
onClick = {
|
|
||||||
navigator.navigate(ExecuteModuleActionScreenDestination(module.dirId))
|
|
||||||
viewModel.markNeedRefresh()
|
|
||||||
},
|
|
||||||
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
|
||||||
colors = ButtonDefaults.filledTonalButtonColors()
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
imageVector = Icons.Outlined.PlayArrow,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
//if (!module.hasWebUi && updateUrl.isEmpty()) {
|
|
||||||
//Text(
|
|
||||||
// modifier = Modifier.padding(start = 7.dp),
|
|
||||||
// text = stringResource(R.string.action),
|
|
||||||
// fontFamily = MaterialTheme.typography.labelMedium.fontFamily,
|
|
||||||
// fontSize = MaterialTheme.typography.labelMedium.fontSize
|
|
||||||
//)
|
|
||||||
//}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (module.hasWebUi) {
|
|
||||||
FilledTonalButton(
|
|
||||||
modifier = Modifier.defaultMinSize(minWidth = 52.dp, minHeight = 32.dp),
|
|
||||||
enabled = !module.remove && module.enabled,
|
|
||||||
onClick = { onClick(module) },
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
|
||||||
colors = ButtonDefaults.filledTonalButtonColors()
|
|
||||||
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
imageVector = Icons.AutoMirrored.Outlined.Wysiwyg,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
//if (!module.hasActionScript && updateUrl.isEmpty()) {
|
|
||||||
//Text(
|
|
||||||
// modifier = Modifier.padding(start = 7.dp),
|
|
||||||
// fontFamily = MaterialTheme.typography.labelMedium.fontFamily,
|
|
||||||
// fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
|
||||||
// text = stringResource(R.string.open)
|
|
||||||
//)
|
|
||||||
//}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f, true))
|
|
||||||
|
|
||||||
if (updateUrl.isNotEmpty()) {
|
|
||||||
Button(
|
|
||||||
modifier = Modifier.defaultMinSize(minWidth = 52.dp, minHeight = 32.dp),
|
|
||||||
enabled = !module.remove,
|
|
||||||
onClick = { onUpdate(module) },
|
|
||||||
shape = ButtonDefaults.textShape,
|
|
||||||
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
imageVector = Icons.Outlined.Download,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
//if (!module.hasActionScript || !module.hasWebUi) {
|
|
||||||
//Text(
|
|
||||||
// modifier = Modifier.padding(start = 7.dp),
|
|
||||||
// fontFamily = MaterialTheme.typography.labelMedium.fontFamily,
|
|
||||||
// fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
|
||||||
// text = stringResource(R.string.module_update)
|
|
||||||
//)
|
|
||||||
//}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FilledTonalButton(
|
|
||||||
modifier = Modifier.defaultMinSize(minWidth = 52.dp, minHeight = 32.dp),
|
|
||||||
onClick = { onUninstallClicked(module) },
|
|
||||||
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
|
||||||
containerColor = if (!module.remove) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer)
|
|
||||||
) {
|
|
||||||
if (!module.remove) {
|
|
||||||
Icon(
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
imageVector = Icons.Outlined.Delete,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
modifier = Modifier.size(20.dp).rotate(180f),
|
|
||||||
imageVector = Icons.Outlined.Refresh,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
//if (!module.hasActionScript && !module.hasWebUi && updateUrl.isEmpty()) {
|
|
||||||
//Text(
|
|
||||||
// modifier = Modifier.padding(start = 7.dp),
|
|
||||||
// fontFamily = MaterialTheme.typography.labelMedium.fontFamily,
|
|
||||||
// fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
|
||||||
// text = stringResource(if (!module.remove) R.string.uninstall else R.string.restore),
|
|
||||||
// color = if (!module.remove) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
//)
|
|
||||||
//}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun ModuleItemPreview() {
|
|
||||||
val module = ModuleViewModel.ModuleInfo(
|
|
||||||
id = "id",
|
|
||||||
name = "name",
|
|
||||||
version = "version",
|
|
||||||
versionCode = 1,
|
|
||||||
author = "author",
|
|
||||||
description = "I am a test module and i do nothing but show a very long description",
|
|
||||||
enabled = true,
|
|
||||||
update = true,
|
|
||||||
remove = false,
|
|
||||||
updateJson = "",
|
|
||||||
hasWebUi = false,
|
|
||||||
hasActionScript = false,
|
|
||||||
dirId = "dirId"
|
|
||||||
)
|
|
||||||
ModuleItem(EmptyDestinationsNavigator, module, "", {}, {}, {}, {})
|
|
||||||
}
|
|
||||||
@@ -1,925 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.screen
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.NavigateNext
|
|
||||||
import androidx.compose.material.icons.filled.Brush
|
|
||||||
import androidx.compose.material.icons.filled.ColorLens
|
|
||||||
import androidx.compose.material.icons.filled.DarkMode
|
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
|
||||||
import androidx.compose.material.icons.filled.Opacity
|
|
||||||
import androidx.compose.material.icons.filled.Palette
|
|
||||||
import androidx.compose.material.icons.filled.Security
|
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
|
||||||
import androidx.compose.material.icons.filled.Wallpaper
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.ListItemDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Slider
|
|
||||||
import androidx.compose.material3.SliderDefaults
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
|
||||||
import com.sukisu.ultra.Natives
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import com.sukisu.ultra.ksuApp
|
|
||||||
import com.sukisu.ultra.ui.component.ImageEditorDialog
|
|
||||||
import com.sukisu.ultra.ui.component.SwitchItem
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig
|
|
||||||
import com.sukisu.ultra.ui.theme.ThemeColors
|
|
||||||
import com.sukisu.ultra.ui.theme.ThemeConfig
|
|
||||||
import com.sukisu.ultra.ui.theme.saveAndApplyCustomBackground
|
|
||||||
import com.sukisu.ultra.ui.theme.saveCustomBackground
|
|
||||||
import com.sukisu.ultra.ui.theme.saveDynamicColorState
|
|
||||||
import com.sukisu.ultra.ui.theme.saveThemeColors
|
|
||||||
import com.sukisu.ultra.ui.theme.saveThemeMode
|
|
||||||
import com.sukisu.ultra.ui.util.getSuSFS
|
|
||||||
import com.sukisu.ultra.ui.util.getSuSFSFeatures
|
|
||||||
import com.sukisu.ultra.ui.util.susfsSUS_SU_0
|
|
||||||
import com.sukisu.ultra.ui.util.susfsSUS_SU_2
|
|
||||||
import com.sukisu.ultra.ui.util.susfsSUS_SU_Mode
|
|
||||||
import com.topjohnwu.superuser.Shell
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
fun saveCardConfig(context: Context) {
|
|
||||||
CardConfig.save(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Destination<RootGraph>
|
|
||||||
@Composable
|
|
||||||
fun MoreSettingsScreen(navigator: DestinationsNavigator) {
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
|
||||||
val context = LocalContext.current
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
val prefs = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) }
|
|
||||||
val systemIsDark = isSystemInDarkTheme()
|
|
||||||
|
|
||||||
// 主题模式选择
|
|
||||||
var themeMode by remember {
|
|
||||||
mutableIntStateOf(
|
|
||||||
when(ThemeConfig.forceDarkMode) {
|
|
||||||
true -> 2 // 深色
|
|
||||||
false -> 1 // 浅色
|
|
||||||
null -> 0 // 跟随系统
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 动态颜色开关状态
|
|
||||||
var useDynamicColor by remember {
|
|
||||||
mutableStateOf(ThemeConfig.useDynamicColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
var showThemeModeDialog by remember { mutableStateOf(false) }
|
|
||||||
// 主题模式选项
|
|
||||||
val themeOptions = listOf(
|
|
||||||
stringResource(R.string.theme_follow_system),
|
|
||||||
stringResource(R.string.theme_light),
|
|
||||||
stringResource(R.string.theme_dark)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 简洁模式开关状态
|
|
||||||
var isSimpleMode by remember {
|
|
||||||
mutableStateOf(prefs.getBoolean("is_simple_mode", false))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新简洁模式开关状态
|
|
||||||
val onSimpleModeChange = { newValue: Boolean ->
|
|
||||||
prefs.edit { putBoolean("is_simple_mode", newValue) }
|
|
||||||
isSimpleMode = newValue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 隐藏内核版本号开关状态
|
|
||||||
var isHideVersion by remember {
|
|
||||||
mutableStateOf(prefs.getBoolean("is_hide_version", false))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 隐藏内核版本号开关状态
|
|
||||||
val onHideVersionChange = { newValue: Boolean ->
|
|
||||||
prefs.edit { putBoolean("is_hide_version", newValue) }
|
|
||||||
isHideVersion = newValue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 隐藏模块数量等信息开关状态
|
|
||||||
var isHideOtherInfo by remember {
|
|
||||||
mutableStateOf(prefs.getBoolean("is_hide_other_info", false))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 隐藏模块数量等信息开关状态
|
|
||||||
val onHideOtherInfoChange = { newValue: Boolean ->
|
|
||||||
prefs.edit { putBoolean("is_hide_other_info", newValue) }
|
|
||||||
isHideOtherInfo = newValue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 隐藏SuSFS状态开关状态
|
|
||||||
var isHideSusfsStatus by remember {
|
|
||||||
mutableStateOf(prefs.getBoolean("is_hide_susfs_status", false))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 隐藏SuSFS状态开关状态
|
|
||||||
val onHideSusfsStatusChange = { newValue: Boolean ->
|
|
||||||
prefs.edit { putBoolean("is_hide_susfs_status", newValue) }
|
|
||||||
isHideSusfsStatus = newValue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 隐藏链接状态开关状态
|
|
||||||
var isHideLinkCard by remember {
|
|
||||||
mutableStateOf(prefs.getBoolean("is_hide_link_card", false))
|
|
||||||
}
|
|
||||||
|
|
||||||
val onHideLinkCardChange = { newValue: Boolean ->
|
|
||||||
prefs.edit { putBoolean("is_hide_link_card", newValue) }
|
|
||||||
isHideLinkCard = newValue
|
|
||||||
}
|
|
||||||
|
|
||||||
// SELinux状态
|
|
||||||
var selinuxEnabled by remember {
|
|
||||||
mutableStateOf(Shell.cmd("getenforce").exec().out.firstOrNull() == "Enforcing")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 卡片配置状态
|
|
||||||
var cardAlpha by rememberSaveable { mutableFloatStateOf(CardConfig.cardAlpha) }
|
|
||||||
var isCustomBackgroundEnabled by rememberSaveable {
|
|
||||||
mutableStateOf(ThemeConfig.customBackgroundUri != null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图片编辑状态
|
|
||||||
var showImageEditor by remember { mutableStateOf(false) }
|
|
||||||
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
|
|
||||||
|
|
||||||
// 展开/折叠状态
|
|
||||||
var isCustomizeExpanded by remember { mutableStateOf(false) }
|
|
||||||
var isAppearanceExpanded by remember { mutableStateOf(true) }
|
|
||||||
var isAdvancedExpanded by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// 初始化卡片配置
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
// 加载设置
|
|
||||||
CardConfig.load(context)
|
|
||||||
cardAlpha = CardConfig.cardAlpha
|
|
||||||
isCustomBackgroundEnabled = ThemeConfig.customBackgroundUri != null
|
|
||||||
|
|
||||||
// 设置主题模式
|
|
||||||
themeMode = when (ThemeConfig.forceDarkMode) {
|
|
||||||
true -> 2
|
|
||||||
false -> 1
|
|
||||||
null -> 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保卡片样式跟随主题模式
|
|
||||||
when (themeMode) {
|
|
||||||
2 -> { // 深色
|
|
||||||
CardConfig.isUserDarkModeEnabled = true
|
|
||||||
CardConfig.isUserLightModeEnabled = false
|
|
||||||
}
|
|
||||||
1 -> { // 浅色
|
|
||||||
CardConfig.isUserDarkModeEnabled = false
|
|
||||||
CardConfig.isUserLightModeEnabled = true
|
|
||||||
}
|
|
||||||
0 -> { // 跟随系统
|
|
||||||
CardConfig.isUserDarkModeEnabled = false
|
|
||||||
CardConfig.isUserLightModeEnabled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果启用了系统跟随且系统是深色模式,应用深色模式默认值
|
|
||||||
if (themeMode == 0 && systemIsDark) {
|
|
||||||
CardConfig.setDarkModeDefaults()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存设置
|
|
||||||
CardConfig.save(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 主题色选项
|
|
||||||
val themeColorOptions = listOf(
|
|
||||||
stringResource(R.string.color_default) to ThemeColors.Default,
|
|
||||||
stringResource(R.string.color_green) to ThemeColors.Green,
|
|
||||||
stringResource(R.string.color_purple) to ThemeColors.Purple,
|
|
||||||
stringResource(R.string.color_orange) to ThemeColors.Orange,
|
|
||||||
stringResource(R.string.color_pink) to ThemeColors.Pink,
|
|
||||||
stringResource(R.string.color_gray) to ThemeColors.Gray,
|
|
||||||
stringResource(R.string.color_yellow) to ThemeColors.Yellow
|
|
||||||
)
|
|
||||||
|
|
||||||
var showThemeColorDialog by remember { mutableStateOf(false) }
|
|
||||||
val ksuIsValid = Natives.isKsuValid(ksuApp.packageName)
|
|
||||||
|
|
||||||
// 图片选择器
|
|
||||||
val pickImageLauncher = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.GetContent()
|
|
||||||
) { uri: Uri? ->
|
|
||||||
uri?.let {
|
|
||||||
selectedImageUri = it
|
|
||||||
showImageEditor = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示图片编辑对话框
|
|
||||||
if (showImageEditor && selectedImageUri != null) {
|
|
||||||
ImageEditorDialog(
|
|
||||||
imageUri = selectedImageUri!!,
|
|
||||||
onDismiss = {
|
|
||||||
showImageEditor = false
|
|
||||||
selectedImageUri = null
|
|
||||||
},
|
|
||||||
onConfirm = { transformedUri ->
|
|
||||||
context.saveAndApplyCustomBackground(transformedUri)
|
|
||||||
isCustomBackgroundEnabled = true
|
|
||||||
CardConfig.cardElevation = 0.dp
|
|
||||||
CardConfig.isCustomBackgroundEnabled = true
|
|
||||||
saveCardConfig(context)
|
|
||||||
showImageEditor = false
|
|
||||||
selectedImageUri = null
|
|
||||||
|
|
||||||
// 显示成功提示
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.background_set_success),
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val cardColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
val cardAlphaUse = CardConfig.cardAlpha
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(stringResource(R.string.more_settings)) },
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = cardColor.copy(alpha = cardAlphaUse),
|
|
||||||
scrolledContainerColor = cardColor.copy(alpha = cardAlphaUse)),
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { navigator.popBackStack() }) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.back))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { paddingValues ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(paddingValues)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
// 外观设置部分
|
|
||||||
SectionHeader(
|
|
||||||
title = stringResource(R.string.appearance_settings),
|
|
||||||
expanded = isAppearanceExpanded,
|
|
||||||
onToggle = { isAppearanceExpanded = !isAppearanceExpanded }
|
|
||||||
)
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = isAppearanceExpanded,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically()
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
tonalElevation = 1.dp,
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
// 主题模式
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text(stringResource(R.string.theme_mode)) },
|
|
||||||
supportingContent = { Text(themeOptions[themeMode]) },
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.DarkMode,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.NavigateNext,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = ListItemDefaults.colors(
|
|
||||||
containerColor = Color.Transparent
|
|
||||||
),
|
|
||||||
modifier = Modifier.clickable { showThemeModeDialog = true }
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
// 动态颜色开关
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
SwitchItem(
|
|
||||||
icon = Icons.Filled.ColorLens,
|
|
||||||
title = stringResource(R.string.dynamic_color_title),
|
|
||||||
summary = stringResource(R.string.dynamic_color_summary),
|
|
||||||
checked = useDynamicColor
|
|
||||||
) { enabled ->
|
|
||||||
useDynamicColor = enabled
|
|
||||||
context.saveDynamicColorState(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只在未启用动态颜色时显示主题色选择
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || !useDynamicColor,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically()
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text(stringResource(R.string.theme_color)) },
|
|
||||||
supportingContent = {
|
|
||||||
val currentThemeName = when (ThemeConfig.currentTheme) {
|
|
||||||
is ThemeColors.Default -> stringResource(R.string.color_default)
|
|
||||||
is ThemeColors.Green -> stringResource(R.string.color_green)
|
|
||||||
is ThemeColors.Purple -> stringResource(R.string.color_purple)
|
|
||||||
is ThemeColors.Orange -> stringResource(R.string.color_orange)
|
|
||||||
is ThemeColors.Pink -> stringResource(R.string.color_pink)
|
|
||||||
is ThemeColors.Gray -> stringResource(R.string.color_gray)
|
|
||||||
is ThemeColors.Yellow -> stringResource(R.string.color_yellow)
|
|
||||||
else -> stringResource(R.string.color_default)
|
|
||||||
}
|
|
||||||
Text(currentThemeName)
|
|
||||||
},
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Palette,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.NavigateNext,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = ListItemDefaults.colors(
|
|
||||||
containerColor = Color.Transparent
|
|
||||||
),
|
|
||||||
modifier = Modifier.clickable { showThemeColorDialog = true }
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自定义背景开关
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text(stringResource(id = R.string.settings_custom_background)) },
|
|
||||||
supportingContent = { Text(stringResource(id = R.string.settings_custom_background_summary)) },
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Wallpaper,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (isCustomBackgroundEnabled)
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Switch(
|
|
||||||
checked = isCustomBackgroundEnabled,
|
|
||||||
onCheckedChange = { isChecked ->
|
|
||||||
if (isChecked) {
|
|
||||||
pickImageLauncher.launch("image/*")
|
|
||||||
} else {
|
|
||||||
context.saveCustomBackground(null)
|
|
||||||
isCustomBackgroundEnabled = false
|
|
||||||
CardConfig.cardElevation
|
|
||||||
CardConfig.cardAlpha = 1f
|
|
||||||
CardConfig.isCustomAlphaSet = false
|
|
||||||
CardConfig.isCustomBackgroundEnabled = false
|
|
||||||
saveCardConfig(context)
|
|
||||||
cardAlpha = 1f
|
|
||||||
|
|
||||||
// 重置其他相关设置
|
|
||||||
ThemeConfig.needsResetOnThemeChange = true
|
|
||||||
ThemeConfig.preventBackgroundRefresh = false
|
|
||||||
|
|
||||||
context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
|
||||||
.edit {
|
|
||||||
putBoolean(
|
|
||||||
"prevent_background_refresh",
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.background_removed),
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = ListItemDefaults.colors(
|
|
||||||
containerColor = Color.Transparent
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 透明度 Slider
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = ThemeConfig.customBackgroundUri != null,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically(),
|
|
||||||
modifier = Modifier.padding(horizontal = 32.dp)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(bottom = 4.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Opacity,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.settings_card_alpha),
|
|
||||||
style = MaterialTheme.typography.titleSmall
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Text(
|
|
||||||
text = "${(cardAlpha * 100).roundToInt()}%",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Slider(
|
|
||||||
value = cardAlpha,
|
|
||||||
onValueChange = { newValue ->
|
|
||||||
cardAlpha = newValue
|
|
||||||
CardConfig.cardAlpha = newValue
|
|
||||||
CardConfig.isCustomAlphaSet = true
|
|
||||||
prefs.edit {
|
|
||||||
putBoolean("is_custom_alpha_set", true)
|
|
||||||
putFloat("card_alpha", newValue)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onValueChangeFinished = {
|
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
|
||||||
saveCardConfig(context)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
valueRange = 0f..1f,
|
|
||||||
steps = 20,
|
|
||||||
colors = SliderDefaults.colors(
|
|
||||||
thumbColor = MaterialTheme.colorScheme.primary,
|
|
||||||
activeTrackColor = MaterialTheme.colorScheme.primary,
|
|
||||||
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自定义设置部分
|
|
||||||
SectionHeader(
|
|
||||||
title = stringResource(R.string.custom_settings),
|
|
||||||
expanded = isCustomizeExpanded,
|
|
||||||
onToggle = { isCustomizeExpanded = !isCustomizeExpanded }
|
|
||||||
)
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = isCustomizeExpanded,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically()
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
tonalElevation = 1.dp,
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
// 添加简洁模式开关
|
|
||||||
SwitchItem(
|
|
||||||
icon = Icons.Filled.Brush,
|
|
||||||
title = stringResource(R.string.simple_mode),
|
|
||||||
summary = stringResource(R.string.simple_mode_summary),
|
|
||||||
checked = isSimpleMode
|
|
||||||
) {
|
|
||||||
onSimpleModeChange(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
// 隐藏内核部分版本号
|
|
||||||
SwitchItem(
|
|
||||||
icon = Icons.Filled.VisibilityOff,
|
|
||||||
title = stringResource(R.string.hide_kernel_kernelsu_version),
|
|
||||||
summary = stringResource(R.string.hide_kernel_kernelsu_version_summary),
|
|
||||||
checked = isHideVersion
|
|
||||||
) {
|
|
||||||
onHideVersionChange(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
// 模块数量等信息
|
|
||||||
SwitchItem(
|
|
||||||
icon = Icons.Filled.VisibilityOff,
|
|
||||||
title = stringResource(R.string.hide_other_info),
|
|
||||||
summary = stringResource(R.string.hide_other_info_summary),
|
|
||||||
checked = isHideOtherInfo
|
|
||||||
) {
|
|
||||||
onHideOtherInfoChange(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
// SuSFS 状态信息
|
|
||||||
SwitchItem(
|
|
||||||
icon = Icons.Filled.VisibilityOff,
|
|
||||||
title = stringResource(R.string.hide_susfs_status),
|
|
||||||
summary = stringResource(R.string.hide_susfs_status_summary),
|
|
||||||
checked = isHideSusfsStatus
|
|
||||||
) {
|
|
||||||
onHideSusfsStatusChange(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
// 隐藏链接信息
|
|
||||||
SwitchItem(
|
|
||||||
icon = Icons.Filled.VisibilityOff,
|
|
||||||
title = stringResource(R.string.hide_link_card),
|
|
||||||
summary = stringResource(R.string.hide_link_card_summary),
|
|
||||||
checked = isHideLinkCard
|
|
||||||
) {
|
|
||||||
onHideLinkCardChange(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 高级设置部分
|
|
||||||
SectionHeader(
|
|
||||||
title = stringResource(R.string.advanced_settings),
|
|
||||||
expanded = isAdvancedExpanded,
|
|
||||||
onToggle = { isAdvancedExpanded = !isAdvancedExpanded }
|
|
||||||
)
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = isAdvancedExpanded,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically()
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
tonalElevation = 1.dp,
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
// SELinux 开关
|
|
||||||
if (ksuIsValid) {
|
|
||||||
SwitchItem(
|
|
||||||
icon = Icons.Filled.Security,
|
|
||||||
title = stringResource(R.string.selinux),
|
|
||||||
summary = if (selinuxEnabled)
|
|
||||||
stringResource(R.string.selinux_enabled) else
|
|
||||||
stringResource(R.string.selinux_disabled),
|
|
||||||
checked = selinuxEnabled
|
|
||||||
) { enabled ->
|
|
||||||
val command = if (enabled) "setenforce 1" else "setenforce 0"
|
|
||||||
Shell.getShell().newJob().add(command).exec().let { result ->
|
|
||||||
if (result.isSuccess) {
|
|
||||||
selinuxEnabled = enabled
|
|
||||||
// 显示成功提示
|
|
||||||
val message = if (enabled)
|
|
||||||
context.getString(R.string.selinux_enabled_toast)
|
|
||||||
else
|
|
||||||
context.getString(R.string.selinux_disabled_toast)
|
|
||||||
|
|
||||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
|
||||||
} else {
|
|
||||||
// 显示失败提示
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.selinux_change_failed),
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SuSFS 配置(仅在支持时显示)
|
|
||||||
val suSFS = getSuSFS()
|
|
||||||
val isSUS_SU = getSuSFSFeatures()
|
|
||||||
if (suSFS == "Supported" && isSUS_SU == "CONFIG_KSU_SUSFS_SUS_SU") {
|
|
||||||
// 初始化时,默认启用
|
|
||||||
var isEnabled by rememberSaveable {
|
|
||||||
mutableStateOf(true) // 默认启用
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在启动时检查状态
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
// 如果当前模式不是2就强制启用
|
|
||||||
val currentMode = susfsSUS_SU_Mode()
|
|
||||||
val wasManuallyDisabled = prefs.getBoolean("enable_sus_su", true)
|
|
||||||
if (currentMode != "2" && wasManuallyDisabled) {
|
|
||||||
susfsSUS_SU_2() // 强制切换到模式2
|
|
||||||
prefs.edit { putBoolean("enable_sus_su", true) }
|
|
||||||
}
|
|
||||||
isEnabled = currentMode == "2"
|
|
||||||
}
|
|
||||||
|
|
||||||
SwitchItem(
|
|
||||||
icon = Icons.Filled.Security,
|
|
||||||
title = stringResource(id = R.string.settings_susfs_toggle),
|
|
||||||
summary = stringResource(id = R.string.settings_susfs_toggle_summary),
|
|
||||||
checked = isEnabled
|
|
||||||
) {
|
|
||||||
if (it) {
|
|
||||||
// 手动启用
|
|
||||||
susfsSUS_SU_2()
|
|
||||||
prefs.edit { putBoolean("enable_sus_su", true) }
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.susfs_enabled),
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
} else {
|
|
||||||
// 手动关闭
|
|
||||||
susfsSUS_SU_0()
|
|
||||||
prefs.edit { putBoolean("enable_sus_su", false) }
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.susfs_disabled),
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
isEnabled = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 主题模式选择对话框
|
|
||||||
if (showThemeModeDialog) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showThemeModeDialog = false },
|
|
||||||
title = { Text(stringResource(R.string.theme_mode)) },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
themeOptions.forEachIndexed { index, option ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable {
|
|
||||||
themeMode = index
|
|
||||||
val newThemeMode = when(index) {
|
|
||||||
0 -> null // 跟随系统
|
|
||||||
1 -> false // 浅色
|
|
||||||
2 -> true // 深色
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
context.saveThemeMode(newThemeMode)
|
|
||||||
when (index) {
|
|
||||||
2 -> { // 深色
|
|
||||||
ThemeConfig.forceDarkMode = true
|
|
||||||
CardConfig.isUserDarkModeEnabled = true
|
|
||||||
CardConfig.isUserLightModeEnabled = false
|
|
||||||
if (!CardConfig.isCustomAlphaSet) {
|
|
||||||
CardConfig.cardAlpha = 1f
|
|
||||||
}
|
|
||||||
CardConfig.save(context)
|
|
||||||
}
|
|
||||||
1 -> { // 浅色
|
|
||||||
ThemeConfig.forceDarkMode = false
|
|
||||||
CardConfig.isUserLightModeEnabled = true
|
|
||||||
CardConfig.isUserDarkModeEnabled = false
|
|
||||||
if (!CardConfig.isCustomAlphaSet) {
|
|
||||||
CardConfig.cardAlpha = 1f
|
|
||||||
}
|
|
||||||
CardConfig.save(context)
|
|
||||||
}
|
|
||||||
0 -> { // 跟随系统
|
|
||||||
ThemeConfig.forceDarkMode = null
|
|
||||||
CardConfig.isUserLightModeEnabled = false
|
|
||||||
CardConfig.isUserDarkModeEnabled = false
|
|
||||||
CardConfig.save(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showThemeModeDialog = false
|
|
||||||
}
|
|
||||||
.padding(vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
RadioButton(
|
|
||||||
selected = themeMode == index,
|
|
||||||
onClick = null
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(option)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showThemeModeDialog = false }
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.cancel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 主题色选择对话框
|
|
||||||
if (showThemeColorDialog) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showThemeColorDialog = false },
|
|
||||||
title = { Text(stringResource(R.string.choose_theme_color)) },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
themeColorOptions.forEach { (name, theme) ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable {
|
|
||||||
context.saveThemeColors(when (theme) {
|
|
||||||
ThemeColors.Default -> "default"
|
|
||||||
ThemeColors.Green -> "green"
|
|
||||||
ThemeColors.Purple -> "purple"
|
|
||||||
ThemeColors.Orange -> "orange"
|
|
||||||
ThemeColors.Pink -> "pink"
|
|
||||||
ThemeColors.Gray -> "gray"
|
|
||||||
ThemeColors.Yellow -> "yellow"
|
|
||||||
else -> "default"
|
|
||||||
})
|
|
||||||
showThemeColorDialog = false
|
|
||||||
}
|
|
||||||
.padding(vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
RadioButton(
|
|
||||||
selected = ThemeConfig.currentTheme::class == theme::class,
|
|
||||||
onClick = null
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(theme.Primary)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Text(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showThemeColorDialog = false }
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.cancel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SectionHeader(
|
|
||||||
title: String,
|
|
||||||
expanded: Boolean,
|
|
||||||
onToggle: () -> Unit
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onToggle() }
|
|
||||||
.padding(vertical = 12.dp, horizontal = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Icon(
|
|
||||||
imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
|
|
||||||
contentDescription = if (expanded)
|
|
||||||
stringResource(R.string.collapse)
|
|
||||||
else
|
|
||||||
stringResource(R.string.expand),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,722 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.screen
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.Undo
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.IconSource
|
|
||||||
import com.maxkeppeler.sheets.list.models.ListOption
|
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.AppProfileTemplateScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.MoreSettingsScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import com.sukisu.ultra.BuildConfig
|
|
||||||
import com.sukisu.ultra.Natives
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import com.sukisu.ultra.*
|
|
||||||
import com.sukisu.ultra.ui.component.*
|
|
||||||
import com.sukisu.ultra.ui.theme.*
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig.cardElevation
|
|
||||||
import com.sukisu.ultra.ui.util.LocalSnackbarHost
|
|
||||||
import com.sukisu.ultra.ui.util.getBugreportFile
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Destination<RootGraph>
|
|
||||||
@Composable
|
|
||||||
fun SettingScreen(navigator: DestinationsNavigator) {
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
|
||||||
val snackBarHost = LocalSnackbarHost.current
|
|
||||||
val ksuIsValid = Natives.isKsuValid(ksuApp.packageName)
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopBar(
|
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = { SnackbarHost(snackBarHost) },
|
|
||||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
|
||||||
) { paddingValues ->
|
|
||||||
val aboutDialog = rememberCustomDialog {
|
|
||||||
AboutDialog(it)
|
|
||||||
}
|
|
||||||
val loadingDialog = rememberLoadingDialog()
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(paddingValues)
|
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
) {
|
|
||||||
// region 上下文与协程
|
|
||||||
val context = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
// region 日志导出功能
|
|
||||||
val exportBugreportLauncher = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.CreateDocument("application/gzip")
|
|
||||||
) { uri: Uri? ->
|
|
||||||
if (uri == null) return@rememberLauncherForActivityResult
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
loadingDialog.show()
|
|
||||||
context.contentResolver.openOutputStream(uri)?.use { output ->
|
|
||||||
getBugreportFile(context).inputStream().use {
|
|
||||||
it.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadingDialog.hide()
|
|
||||||
snackBarHost.showSnackbar(context.getString(R.string.log_saved))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置分组卡片 - 配置
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = cardAlpha)
|
|
||||||
),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.configuration),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 配置文件模板入口
|
|
||||||
val profileTemplate = stringResource(id = R.string.settings_profile_template)
|
|
||||||
if (ksuIsValid) {
|
|
||||||
SettingItem(
|
|
||||||
icon = Icons.Filled.Fence,
|
|
||||||
title = profileTemplate,
|
|
||||||
summary = stringResource(id = R.string.settings_profile_template_summary),
|
|
||||||
onClick = {
|
|
||||||
navigator.navigate(AppProfileTemplateScreenDestination)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 卸载模块开关
|
|
||||||
var umountChecked by rememberSaveable {
|
|
||||||
mutableStateOf(Natives.isDefaultUmountModules())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ksuIsValid) {
|
|
||||||
SwitchSettingItem(
|
|
||||||
icon = Icons.Filled.FolderDelete,
|
|
||||||
title = stringResource(id = R.string.settings_umount_modules_default),
|
|
||||||
summary = stringResource(id = R.string.settings_umount_modules_default_summary),
|
|
||||||
checked = umountChecked,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (Natives.setDefaultUmountModules(it)) {
|
|
||||||
umountChecked = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SU 禁用开关(仅在兼容版本显示)
|
|
||||||
if (ksuIsValid) {
|
|
||||||
if (Natives.version >= Natives.MINIMAL_SUPPORTED_SU_COMPAT) {
|
|
||||||
var isSuDisabled by rememberSaveable {
|
|
||||||
mutableStateOf(!Natives.isSuEnabled())
|
|
||||||
}
|
|
||||||
SwitchSettingItem(
|
|
||||||
icon = Icons.Filled.RemoveModerator,
|
|
||||||
title = stringResource(id = R.string.settings_disable_su),
|
|
||||||
summary = stringResource(id = R.string.settings_disable_su_summary),
|
|
||||||
checked = isSuDisabled,
|
|
||||||
onCheckedChange = { checked ->
|
|
||||||
val shouldEnable = !checked
|
|
||||||
if (Natives.setSuEnabled(shouldEnable)) {
|
|
||||||
isSuDisabled = !shouldEnable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置分组卡片 - 应用设置
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = cardAlpha)
|
|
||||||
),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.app_settings),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
// 更新检查开关
|
|
||||||
var checkUpdate by rememberSaveable {
|
|
||||||
mutableStateOf(
|
|
||||||
prefs.getBoolean("check_update", true)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
SwitchSettingItem(
|
|
||||||
icon = Icons.Filled.Update,
|
|
||||||
title = stringResource(id = R.string.settings_check_update),
|
|
||||||
summary = stringResource(id = R.string.settings_check_update_summary),
|
|
||||||
checked = checkUpdate,
|
|
||||||
onCheckedChange = {
|
|
||||||
prefs.edit {putBoolean("check_update", it) }
|
|
||||||
checkUpdate = it
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Web调试开关
|
|
||||||
var enableWebDebugging by rememberSaveable {
|
|
||||||
mutableStateOf(
|
|
||||||
prefs.getBoolean("enable_web_debugging", false)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (Natives.isKsuValid(ksuApp.packageName)) {
|
|
||||||
SwitchSettingItem(
|
|
||||||
icon = Icons.Filled.DeveloperMode,
|
|
||||||
title = stringResource(id = R.string.enable_web_debugging),
|
|
||||||
summary = stringResource(id = R.string.enable_web_debugging_summary),
|
|
||||||
checked = enableWebDebugging,
|
|
||||||
onCheckedChange = {
|
|
||||||
prefs.edit { putBoolean("enable_web_debugging", it) }
|
|
||||||
enableWebDebugging = it
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更多设置
|
|
||||||
SettingItem(
|
|
||||||
icon = Icons.Filled.Settings,
|
|
||||||
title = stringResource(id = R.string.more_settings),
|
|
||||||
summary = stringResource(id = R.string.more_settings),
|
|
||||||
onClick = {
|
|
||||||
navigator.navigate(MoreSettingsScreenDestination)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置分组卡片 - 工具
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = cardAlpha)
|
|
||||||
),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.tools),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
var showBottomsheet by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
SettingItem(
|
|
||||||
icon = Icons.Filled.BugReport,
|
|
||||||
title = stringResource(id = R.string.send_log),
|
|
||||||
onClick = {
|
|
||||||
showBottomsheet = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (showBottomsheet) {
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = { showBottomsheet = false },
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
|
||||||
) {
|
|
||||||
LogActionButton(
|
|
||||||
icon = Icons.Filled.Save,
|
|
||||||
text = stringResource(R.string.save_log),
|
|
||||||
onClick = {
|
|
||||||
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH_mm")
|
|
||||||
val current = LocalDateTime.now().format(formatter)
|
|
||||||
exportBugreportLauncher.launch("KernelSU_bugreport_${current}.tar.gz")
|
|
||||||
showBottomsheet = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
LogActionButton(
|
|
||||||
icon = Icons.Filled.Share,
|
|
||||||
text = stringResource(R.string.send_log),
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
val bugreport = loadingDialog.withLoading {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
getBugreportFile(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val uri: Uri =
|
|
||||||
FileProvider.getUriForFile(
|
|
||||||
context,
|
|
||||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
|
||||||
bugreport
|
|
||||||
)
|
|
||||||
|
|
||||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
|
||||||
setDataAndType(uri, "application/gzip")
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.startActivity(
|
|
||||||
Intent.createChooser(
|
|
||||||
shareIntent,
|
|
||||||
context.getString(R.string.send_log)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
showBottomsheet = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val lkmMode = Natives.version >= Natives.MINIMAL_SUPPORTED_KERNEL_LKM && Natives.isLkmMode
|
|
||||||
if (lkmMode) {
|
|
||||||
UninstallItem(navigator) {
|
|
||||||
loadingDialog.withLoading(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置分组卡片 - 关于
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = cardAlpha)
|
|
||||||
),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.about),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
SettingItem(
|
|
||||||
icon = Icons.Filled.Info,
|
|
||||||
title = stringResource(R.string.about),
|
|
||||||
onClick = {
|
|
||||||
aboutDialog.show()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LogActionButton(
|
|
||||||
icon: ImageVector,
|
|
||||||
text: String,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(8.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(56.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = text,
|
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SettingItem(
|
|
||||||
icon: ImageVector,
|
|
||||||
title: String,
|
|
||||||
summary: String? = null,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(end = 16.dp)
|
|
||||||
.size(24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
if (summary != null) {
|
|
||||||
Text(
|
|
||||||
text = summary,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.ChevronRight,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SwitchSettingItem(
|
|
||||||
icon: ImageVector,
|
|
||||||
title: String,
|
|
||||||
summary: String? = null,
|
|
||||||
checked: Boolean,
|
|
||||||
onCheckedChange: (Boolean) -> Unit
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onCheckedChange(!checked) }
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(end = 16.dp)
|
|
||||||
.size(24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
if (summary != null) {
|
|
||||||
Text(
|
|
||||||
text = summary,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Switch(
|
|
||||||
checked = checked,
|
|
||||||
onCheckedChange = onCheckedChange,
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
checkedTrackColor = MaterialTheme.colorScheme.primary,
|
|
||||||
checkedIconColor = MaterialTheme.colorScheme.primary,
|
|
||||||
uncheckedThumbColor = MaterialTheme.colorScheme.outline,
|
|
||||||
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
uncheckedIconColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun UninstallItem(
|
|
||||||
navigator: DestinationsNavigator,
|
|
||||||
withLoading: suspend (suspend () -> Unit) -> Unit
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val uninstallConfirmDialog = rememberConfirmDialog()
|
|
||||||
val showTodo = {
|
|
||||||
Toast.makeText(context, "TODO", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
val uninstallDialog = rememberUninstallDialog { uninstallType ->
|
|
||||||
scope.launch {
|
|
||||||
val result = uninstallConfirmDialog.awaitConfirm(
|
|
||||||
title = context.getString(uninstallType.title),
|
|
||||||
content = context.getString(uninstallType.message)
|
|
||||||
)
|
|
||||||
if (result == ConfirmResult.Confirmed) {
|
|
||||||
withLoading {
|
|
||||||
when (uninstallType) {
|
|
||||||
UninstallType.TEMPORARY -> showTodo()
|
|
||||||
UninstallType.PERMANENT -> navigator.navigate(
|
|
||||||
FlashScreenDestination(FlashIt.FlashUninstall)
|
|
||||||
)
|
|
||||||
UninstallType.RESTORE_STOCK_IMAGE -> navigator.navigate(
|
|
||||||
FlashScreenDestination(FlashIt.FlashRestore)
|
|
||||||
)
|
|
||||||
UninstallType.NONE -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingItem(
|
|
||||||
icon = Icons.Filled.Delete,
|
|
||||||
title = stringResource(id = R.string.settings_uninstall),
|
|
||||||
onClick = {
|
|
||||||
uninstallDialog.show()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class UninstallType(val title: Int, val message: Int, val icon: ImageVector) {
|
|
||||||
TEMPORARY(
|
|
||||||
R.string.settings_uninstall_temporary,
|
|
||||||
R.string.settings_uninstall_temporary_message,
|
|
||||||
Icons.Filled.Delete
|
|
||||||
),
|
|
||||||
PERMANENT(
|
|
||||||
R.string.settings_uninstall_permanent,
|
|
||||||
R.string.settings_uninstall_permanent_message,
|
|
||||||
Icons.Filled.DeleteForever
|
|
||||||
),
|
|
||||||
RESTORE_STOCK_IMAGE(
|
|
||||||
R.string.settings_restore_stock_image,
|
|
||||||
R.string.settings_restore_stock_image_message,
|
|
||||||
Icons.AutoMirrored.Filled.Undo
|
|
||||||
),
|
|
||||||
NONE(0, 0, Icons.Filled.Delete)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun rememberUninstallDialog(onSelected: (UninstallType) -> Unit): DialogHandle {
|
|
||||||
return rememberCustomDialog { dismiss ->
|
|
||||||
val options = listOf(
|
|
||||||
// UninstallType.TEMPORARY,
|
|
||||||
UninstallType.PERMANENT,
|
|
||||||
UninstallType.RESTORE_STOCK_IMAGE
|
|
||||||
)
|
|
||||||
val listOptions = options.map {
|
|
||||||
ListOption(
|
|
||||||
titleText = stringResource(it.title),
|
|
||||||
subtitleText = if (it.message != 0) stringResource(it.message) else null,
|
|
||||||
icon = IconSource(it.icon)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var selection = UninstallType.NONE
|
|
||||||
val cardColor = if (!ThemeConfig.useDynamicColor) {
|
|
||||||
ThemeConfig.currentTheme.ButtonContrast
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = {
|
|
||||||
dismiss()
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.settings_uninstall),
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(vertical = 8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
listOptions.forEachIndexed { index, option ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.clickable {
|
|
||||||
selection = options[index]
|
|
||||||
}
|
|
||||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = options[index].icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(end = 16.dp)
|
|
||||||
.size(24.dp)
|
|
||||||
)
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = option.titleText,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
option.subtitleText?.let {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
if (selection != UninstallType.NONE) {
|
|
||||||
onSelected(selection)
|
|
||||||
}
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(android.R.string.ok),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(android.R.string.cancel),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
containerColor = cardColor,
|
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
|
||||||
tonalElevation = 4.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun TopBar(
|
|
||||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
|
||||||
) {
|
|
||||||
val systemIsDark = isSystemInDarkTheme()
|
|
||||||
val cardColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
val cardAlpha = if (ThemeConfig.customBackgroundUri != null) {
|
|
||||||
cardAlpha
|
|
||||||
} else {
|
|
||||||
if (systemIsDark) 0.8f else 1f
|
|
||||||
}
|
|
||||||
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.settings),
|
|
||||||
style = MaterialTheme.typography.titleLarge
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
|
||||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
|
||||||
),
|
|
||||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,594 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.screen
|
|
||||||
|
|
||||||
import androidx.compose.animation.*
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import coil.request.ImageRequest
|
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.AppProfileScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import com.sukisu.ultra.Natives
|
|
||||||
import com.sukisu.ultra.ui.component.SearchAppBar
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig.cardElevation
|
|
||||||
import com.sukisu.ultra.ui.util.ModuleModify
|
|
||||||
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
|
||||||
@Destination<RootGraph>
|
|
||||||
@Composable
|
|
||||||
fun SuperUserScreen(navigator: DestinationsNavigator) {
|
|
||||||
val viewModel = viewModel<SuperUserViewModel>()
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
val context = LocalContext.current
|
|
||||||
val snackBarHostState = remember { SnackbarHostState() }
|
|
||||||
|
|
||||||
// 添加备份和还原启动器
|
|
||||||
val backupLauncher = ModuleModify.rememberAllowlistBackupLauncher(context, snackBarHostState)
|
|
||||||
val restoreLauncher = ModuleModify.rememberAllowlistRestoreLauncher(context, snackBarHostState)
|
|
||||||
|
|
||||||
LaunchedEffect(key1 = navigator) {
|
|
||||||
viewModel.search = ""
|
|
||||||
if (viewModel.appList.isEmpty()) {
|
|
||||||
viewModel.fetchAppList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(viewModel.search) {
|
|
||||||
if (viewModel.search.isEmpty()) {
|
|
||||||
listState.scrollToItem(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
SearchAppBar(
|
|
||||||
title = { Text(stringResource(R.string.superuser)) },
|
|
||||||
searchText = viewModel.search,
|
|
||||||
onSearchTextChange = { viewModel.search = it },
|
|
||||||
onClearClick = { viewModel.search = "" },
|
|
||||||
dropdownContent = {
|
|
||||||
var showDropdown by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = { showDropdown = true },
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.MoreVert,
|
|
||||||
contentDescription = stringResource(id = R.string.settings),
|
|
||||||
)
|
|
||||||
|
|
||||||
DropdownMenu(expanded = showDropdown, onDismissRequest = {
|
|
||||||
showDropdown = false
|
|
||||||
}) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.refresh)) },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Refresh,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
viewModel.fetchAppList()
|
|
||||||
}
|
|
||||||
showDropdown = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
if (viewModel.showSystemApps) {
|
|
||||||
stringResource(R.string.hide_system_apps)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.show_system_apps)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (viewModel.showSystemApps)
|
|
||||||
Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
viewModel.showSystemApps = !viewModel.showSystemApps
|
|
||||||
showDropdown = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
HorizontalDivider(thickness = 0.5.dp, modifier = Modifier.padding(vertical = 4.dp))
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.backup_allowlist)) },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Save,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
backupLauncher.launch(ModuleModify.createAllowlistBackupIntent())
|
|
||||||
showDropdown = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.restore_allowlist)) },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.RestoreFromTrash,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
restoreLauncher.launch(ModuleModify.createAllowlistRestoreIntent())
|
|
||||||
showDropdown = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = { SnackbarHost(snackBarHostState) },
|
|
||||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
|
||||||
bottomBar = {
|
|
||||||
// 批量操作按钮,直接放在底部栏
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = viewModel.showBatchActions && viewModel.selectedApps.isNotEmpty(),
|
|
||||||
enter = slideInVertically(initialOffsetY = { it }),
|
|
||||||
exit = slideOutVertically(targetOffsetY = { it })
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
tonalElevation = cardElevation,
|
|
||||||
shadowElevation = cardElevation
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
// 修改为重新赋值为空集合
|
|
||||||
viewModel.selectedApps = emptySet()
|
|
||||||
viewModel.showBatchActions = false
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Close,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
viewModel.updateBatchPermissions(true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(stringResource(R.string.batch_authorization))
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
viewModel.updateBatchPermissions(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Block,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(stringResource(R.string.batch_cancel_authorization))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) { innerPadding ->
|
|
||||||
PullToRefreshBox(
|
|
||||||
modifier = Modifier.padding(innerPadding),
|
|
||||||
onRefresh = {
|
|
||||||
scope.launch { viewModel.fetchAppList() }
|
|
||||||
},
|
|
||||||
isRefreshing = viewModel.isRefreshing
|
|
||||||
) {
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
|
||||||
contentPadding = PaddingValues(
|
|
||||||
top = 8.dp,
|
|
||||||
bottom = if (viewModel.showBatchActions && viewModel.selectedApps.isNotEmpty()) 88.dp else 16.dp
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
// 获取分组后的应用列表
|
|
||||||
val rootApps = viewModel.appList.filter { it.allowSu }
|
|
||||||
val customApps = viewModel.appList.filter { !it.allowSu && it.hasCustomProfile }
|
|
||||||
val otherApps = viewModel.appList.filter { !it.allowSu && !it.hasCustomProfile }
|
|
||||||
|
|
||||||
// 显示ROOT权限应用组
|
|
||||||
if (rootApps.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
GroupHeader(title = stringResource(R.string.apps_with_root))
|
|
||||||
}
|
|
||||||
|
|
||||||
items(rootApps, key = { "root_" + it.packageName + it.uid }) { app ->
|
|
||||||
AppItem(
|
|
||||||
app = app,
|
|
||||||
isSelected = viewModel.selectedApps.contains(app.packageName),
|
|
||||||
onToggleSelection = { viewModel.toggleAppSelection(app.packageName) },
|
|
||||||
onSwitchChange = { allowSu ->
|
|
||||||
scope.launch {
|
|
||||||
val profile = Natives.getAppProfile(app.packageName, app.uid)
|
|
||||||
val updatedProfile = profile.copy(allowSu = allowSu)
|
|
||||||
if (Natives.setAppProfile(updatedProfile)) {
|
|
||||||
viewModel.fetchAppList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
if (viewModel.showBatchActions) {
|
|
||||||
viewModel.toggleAppSelection(app.packageName)
|
|
||||||
} else {
|
|
||||||
navigator.navigate(AppProfileScreenDestination(app))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongClick = {
|
|
||||||
// 长按进入多选模式
|
|
||||||
if (!viewModel.showBatchActions) {
|
|
||||||
viewModel.toggleBatchMode()
|
|
||||||
viewModel.toggleAppSelection(app.packageName)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
viewModel = viewModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示自定义配置应用组
|
|
||||||
if (customApps.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
GroupHeader(title = stringResource(R.string.apps_with_custom_profile))
|
|
||||||
}
|
|
||||||
|
|
||||||
items(customApps, key = { "custom_" + it.packageName + it.uid }) { app ->
|
|
||||||
AppItem(
|
|
||||||
app = app,
|
|
||||||
isSelected = viewModel.selectedApps.contains(app.packageName),
|
|
||||||
onToggleSelection = { viewModel.toggleAppSelection(app.packageName) },
|
|
||||||
onSwitchChange = { allowSu ->
|
|
||||||
scope.launch {
|
|
||||||
val profile = Natives.getAppProfile(app.packageName, app.uid)
|
|
||||||
val updatedProfile = profile.copy(allowSu = allowSu)
|
|
||||||
if (Natives.setAppProfile(updatedProfile)) {
|
|
||||||
viewModel.fetchAppList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
if (viewModel.showBatchActions) {
|
|
||||||
viewModel.toggleAppSelection(app.packageName)
|
|
||||||
} else {
|
|
||||||
navigator.navigate(AppProfileScreenDestination(app))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongClick = {
|
|
||||||
// 长按进入多选模式
|
|
||||||
if (!viewModel.showBatchActions) {
|
|
||||||
viewModel.toggleBatchMode()
|
|
||||||
viewModel.toggleAppSelection(app.packageName)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
viewModel = viewModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示其他应用组
|
|
||||||
if (otherApps.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
GroupHeader(title = stringResource(R.string.other_apps))
|
|
||||||
}
|
|
||||||
|
|
||||||
items(otherApps, key = { "other_" + it.packageName + it.uid }) { app ->
|
|
||||||
AppItem(
|
|
||||||
app = app,
|
|
||||||
isSelected = viewModel.selectedApps.contains(app.packageName),
|
|
||||||
onToggleSelection = { viewModel.toggleAppSelection(app.packageName) },
|
|
||||||
onSwitchChange = { allowSu ->
|
|
||||||
scope.launch {
|
|
||||||
val profile = Natives.getAppProfile(app.packageName, app.uid)
|
|
||||||
val updatedProfile = profile.copy(allowSu = allowSu)
|
|
||||||
if (Natives.setAppProfile(updatedProfile)) {
|
|
||||||
viewModel.fetchAppList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
if (viewModel.showBatchActions) {
|
|
||||||
viewModel.toggleAppSelection(app.packageName)
|
|
||||||
} else {
|
|
||||||
navigator.navigate(AppProfileScreenDestination(app))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongClick = {
|
|
||||||
// 长按进入多选模式
|
|
||||||
if (!viewModel.showBatchActions) {
|
|
||||||
viewModel.toggleBatchMode()
|
|
||||||
viewModel.toggleAppSelection(app.packageName)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
viewModel = viewModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 当没有应用显示时显示空状态
|
|
||||||
if (viewModel.appList.isEmpty()) {
|
|
||||||
item {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(400.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Apps,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f),
|
|
||||||
modifier = Modifier
|
|
||||||
.size(96.dp)
|
|
||||||
.padding(bottom = 16.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.no_apps_found),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GroupHeader(title: String) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.7f))
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
|
||||||
private fun AppItem(
|
|
||||||
app: SuperUserViewModel.AppInfo,
|
|
||||||
isSelected: Boolean,
|
|
||||||
onToggleSelection: () -> Unit,
|
|
||||||
onSwitchChange: (Boolean) -> Unit,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onLongClick: () -> Unit,
|
|
||||||
viewModel: SuperUserViewModel
|
|
||||||
) {
|
|
||||||
val cardColor = if (app.allowSu)
|
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
|
||||||
else if (app.hasCustomProfile)
|
|
||||||
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f)
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerLow
|
|
||||||
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(containerColor = cardColor),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.shadow(
|
|
||||||
elevation = 0.dp,
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
if (isSelected)
|
|
||||||
Modifier.border(
|
|
||||||
width = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Modifier
|
|
||||||
)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onLongPress = { onLongClick() },
|
|
||||||
onTap = { onClick() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
model = ImageRequest.Builder(LocalContext.current)
|
|
||||||
.data(app.packageInfo)
|
|
||||||
.crossfade(true)
|
|
||||||
.build(),
|
|
||||||
contentDescription = app.label,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(end = 16.dp)
|
|
||||||
.size(48.dp)
|
|
||||||
.clip(MaterialTheme.shapes.small)
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = app.label,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = app.packageName,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
|
|
||||||
FlowRow(
|
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
if (app.allowSu) {
|
|
||||||
LabelText(label = "ROOT", backgroundColor = MaterialTheme.colorScheme.primary)
|
|
||||||
}
|
|
||||||
if (Natives.uidShouldUmount(app.uid)) {
|
|
||||||
LabelText(label = "UMOUNT", backgroundColor = MaterialTheme.colorScheme.tertiary)
|
|
||||||
}
|
|
||||||
if (app.hasCustomProfile) {
|
|
||||||
LabelText(label = "CUSTOM", backgroundColor = MaterialTheme.colorScheme.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!viewModel.showBatchActions) {
|
|
||||||
Switch(
|
|
||||||
checked = app.allowSu,
|
|
||||||
onCheckedChange = onSwitchChange,
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
checkedTrackColor = MaterialTheme.colorScheme.primary,
|
|
||||||
checkedIconColor = MaterialTheme.colorScheme.primary,
|
|
||||||
uncheckedThumbColor = MaterialTheme.colorScheme.outline,
|
|
||||||
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
uncheckedIconColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Checkbox(
|
|
||||||
checked = isSelected,
|
|
||||||
onCheckedChange = { onToggleSelection() },
|
|
||||||
colors = CheckboxDefaults.colors(
|
|
||||||
checkedColor = MaterialTheme.colorScheme.primary,
|
|
||||||
uncheckedColor = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LabelText(label: String, backgroundColor: Color) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(top = 2.dp, end = 2.dp)
|
|
||||||
.background(
|
|
||||||
backgroundColor,
|
|
||||||
shape = RoundedCornerShape(4.dp)
|
|
||||||
)
|
|
||||||
.clip(RoundedCornerShape(4.dp))
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
modifier = Modifier.padding(vertical = 2.dp, horizontal = 6.dp),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 10.sp,
|
|
||||||
color = Color.White,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.screen
|
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.only
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material.icons.filled.ImportExport
|
|
||||||
import androidx.compose.material.icons.filled.Sync
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarColors
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.core.content.getSystemService
|
|
||||||
import androidx.lifecycle.compose.dropUnlessResumed
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.TemplateEditorScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
|
||||||
import com.ramcosta.composedestinations.result.ResultRecipient
|
|
||||||
import com.ramcosta.composedestinations.result.getOr
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig
|
|
||||||
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author weishu
|
|
||||||
* @date 2023/10/20.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
|
||||||
@Destination<RootGraph>
|
|
||||||
@Composable
|
|
||||||
fun AppProfileTemplateScreen(
|
|
||||||
navigator: DestinationsNavigator,
|
|
||||||
resultRecipient: ResultRecipient<TemplateEditorScreenDestination, Boolean>
|
|
||||||
) {
|
|
||||||
val viewModel = viewModel<TemplateViewModel>()
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
if (viewModel.templateList.isEmpty()) {
|
|
||||||
viewModel.fetchTemplates()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle result from TemplateEditorScreen, refresh if needed
|
|
||||||
resultRecipient.onNavResult { result ->
|
|
||||||
if (result.getOr { false }) {
|
|
||||||
scope.launch { viewModel.fetchTemplates() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val cardColorUse = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
val cardAlpha = CardConfig.cardAlpha
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val clipboardManager = context.getSystemService<ClipboardManager>()
|
|
||||||
val showToast = fun(msg: String) {
|
|
||||||
scope.launch(Dispatchers.Main) {
|
|
||||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TopBar(
|
|
||||||
onBack = dropUnlessResumed { navigator.popBackStack() },
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = cardColorUse.copy(alpha = cardAlpha),
|
|
||||||
scrolledContainerColor = cardColorUse.copy(alpha = cardAlpha)
|
|
||||||
),
|
|
||||||
onSync = {
|
|
||||||
scope.launch { viewModel.fetchTemplates(true) }
|
|
||||||
},
|
|
||||||
onImport = {
|
|
||||||
scope.launch {
|
|
||||||
val clipboardText = clipboardManager?.primaryClip?.getItemAt(0)?.text?.toString()
|
|
||||||
if (clipboardText.isNullOrEmpty()) {
|
|
||||||
showToast(context.getString(R.string.app_profile_template_import_empty))
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
viewModel.importTemplates(
|
|
||||||
clipboardText,
|
|
||||||
{
|
|
||||||
showToast(context.getString(R.string.app_profile_template_import_success))
|
|
||||||
viewModel.fetchTemplates(false)
|
|
||||||
},
|
|
||||||
showToast
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onExport = {
|
|
||||||
scope.launch {
|
|
||||||
viewModel.exportTemplates(
|
|
||||||
{
|
|
||||||
showToast(context.getString(R.string.app_profile_template_export_empty))
|
|
||||||
}
|
|
||||||
) { text ->
|
|
||||||
clipboardManager?.setPrimaryClip(ClipData.newPlainText("", text))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
|
||||||
},
|
|
||||||
floatingActionButton = {
|
|
||||||
ExtendedFloatingActionButton(
|
|
||||||
onClick = {
|
|
||||||
navigator.navigate(
|
|
||||||
TemplateEditorScreenDestination(
|
|
||||||
TemplateViewModel.TemplateInfo(),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
icon = { Icon(Icons.Filled.Add, null) },
|
|
||||||
text = { Text(stringResource(id = R.string.app_profile_template_create)) },
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
},
|
|
||||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
|
||||||
) { innerPadding ->
|
|
||||||
PullToRefreshBox(
|
|
||||||
modifier = Modifier.padding(innerPadding),
|
|
||||||
isRefreshing = viewModel.isRefreshing,
|
|
||||||
onRefresh = {
|
|
||||||
scope.launch { viewModel.fetchTemplates() }
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
|
||||||
contentPadding = remember {
|
|
||||||
PaddingValues(bottom = 16.dp + 56.dp + 16.dp /* Scaffold Fab Spacing + Fab container height */)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
items(viewModel.templateList, key = { it.id }) { app ->
|
|
||||||
TemplateItem(navigator, app)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
|
||||||
private fun TemplateItem(
|
|
||||||
navigator: DestinationsNavigator,
|
|
||||||
template: TemplateViewModel.TemplateInfo
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable {
|
|
||||||
navigator.navigate(TemplateEditorScreenDestination(template, !template.local))
|
|
||||||
},
|
|
||||||
headlineContent = { Text(template.name) },
|
|
||||||
supportingContent = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = "${template.id}${if (template.author.isEmpty()) "" else "@${template.author}"}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
|
||||||
)
|
|
||||||
Text(template.description)
|
|
||||||
FlowRow {
|
|
||||||
LabelText(label = "UID: ${template.uid}", backgroundColor = MaterialTheme.colorScheme.surface)
|
|
||||||
LabelText(label = "GID: ${template.gid}", backgroundColor = MaterialTheme.colorScheme.surface)
|
|
||||||
LabelText(label = template.context, backgroundColor = MaterialTheme.colorScheme.surface)
|
|
||||||
if (template.local) {
|
|
||||||
LabelText(label = "local", backgroundColor = MaterialTheme.colorScheme.surface)
|
|
||||||
} else {
|
|
||||||
LabelText(label = "remote", backgroundColor = MaterialTheme.colorScheme.surface)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun TopBar(
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onSync: () -> Unit = {},
|
|
||||||
onImport: () -> Unit = {},
|
|
||||||
onExport: () -> Unit = {},
|
|
||||||
colors: TopAppBarColors,
|
|
||||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
|
||||||
) {
|
|
||||||
val cardColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
val cardAlpha = CardConfig.cardAlpha
|
|
||||||
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(stringResource(R.string.settings_profile_template))
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
|
||||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
|
||||||
),
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(
|
|
||||||
onClick = onBack
|
|
||||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(onClick = onSync) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Sync,
|
|
||||||
contentDescription = stringResource(id = R.string.app_profile_template_sync)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var showDropdown by remember { mutableStateOf(false) }
|
|
||||||
IconButton(onClick = {
|
|
||||||
showDropdown = true
|
|
||||||
}) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.ImportExport,
|
|
||||||
contentDescription = stringResource(id = R.string.app_profile_import_export)
|
|
||||||
)
|
|
||||||
|
|
||||||
DropdownMenu(expanded = showDropdown, onDismissRequest = {
|
|
||||||
showDropdown = false
|
|
||||||
}) {
|
|
||||||
DropdownMenuItem(text = {
|
|
||||||
Text(stringResource(id = R.string.app_profile_import_from_clipboard))
|
|
||||||
}, onClick = {
|
|
||||||
onImport()
|
|
||||||
showDropdown = false
|
|
||||||
})
|
|
||||||
DropdownMenuItem(text = {
|
|
||||||
Text(stringResource(id = R.string.app_profile_export_to_clipboard))
|
|
||||||
}, onClick = {
|
|
||||||
onExport()
|
|
||||||
showDropdown = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.screen
|
|
||||||
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.only
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.DeleteForever
|
|
||||||
import androidx.compose.material.icons.filled.Save
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
|
||||||
import com.ramcosta.composedestinations.result.ResultBackNavigator
|
|
||||||
import com.sukisu.ultra.Natives
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import com.sukisu.ultra.ui.component.profile.RootProfileConfig
|
|
||||||
import com.sukisu.ultra.ui.util.deleteAppProfileTemplate
|
|
||||||
import com.sukisu.ultra.ui.util.getAppProfileTemplate
|
|
||||||
import com.sukisu.ultra.ui.util.setAppProfileTemplate
|
|
||||||
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel
|
|
||||||
import com.sukisu.ultra.ui.viewmodel.toJSON
|
|
||||||
import androidx.lifecycle.compose.dropUnlessResumed
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author weishu
|
|
||||||
* @date 2023/10/20.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
|
||||||
@Destination<RootGraph>
|
|
||||||
@Composable
|
|
||||||
fun TemplateEditorScreen(
|
|
||||||
navigator: ResultBackNavigator<Boolean>,
|
|
||||||
initialTemplate: TemplateViewModel.TemplateInfo,
|
|
||||||
readOnly: Boolean = true,
|
|
||||||
) {
|
|
||||||
|
|
||||||
val isCreation = initialTemplate.id.isBlank()
|
|
||||||
val autoSave = !isCreation
|
|
||||||
|
|
||||||
var template by rememberSaveable {
|
|
||||||
mutableStateOf(initialTemplate)
|
|
||||||
}
|
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
|
||||||
|
|
||||||
BackHandler {
|
|
||||||
navigator.navigateBack(result = !readOnly)
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
val author =
|
|
||||||
if (initialTemplate.author.isNotEmpty()) "@${initialTemplate.author}" else ""
|
|
||||||
val readOnlyHint = if (readOnly) {
|
|
||||||
" - ${stringResource(id = R.string.app_profile_template_readonly)}"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
val titleSummary = "${initialTemplate.id}$author$readOnlyHint"
|
|
||||||
val saveTemplateFailed = stringResource(id = R.string.app_profile_template_save_failed)
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
TopBar(
|
|
||||||
title = if (isCreation) {
|
|
||||||
stringResource(R.string.app_profile_template_create)
|
|
||||||
} else if (readOnly) {
|
|
||||||
stringResource(R.string.app_profile_template_view)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.app_profile_template_edit)
|
|
||||||
},
|
|
||||||
readOnly = readOnly,
|
|
||||||
summary = titleSummary,
|
|
||||||
onBack = dropUnlessResumed { navigator.navigateBack(result = !readOnly) },
|
|
||||||
onDelete = {
|
|
||||||
if (deleteAppProfileTemplate(template.id)) {
|
|
||||||
navigator.navigateBack(result = true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSave = {
|
|
||||||
if (saveTemplate(template, isCreation)) {
|
|
||||||
navigator.navigateBack(result = true)
|
|
||||||
} else {
|
|
||||||
Toast.makeText(context, saveTemplateFailed, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
|
||||||
},
|
|
||||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(innerPadding)
|
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.pointerInteropFilter {
|
|
||||||
// disable click and ripple if readOnly
|
|
||||||
readOnly
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
if (isCreation) {
|
|
||||||
var errorHint by remember {
|
|
||||||
mutableStateOf("")
|
|
||||||
}
|
|
||||||
val idConflictError = stringResource(id = R.string.app_profile_template_id_exist)
|
|
||||||
val idInvalidError = stringResource(id = R.string.app_profile_template_id_invalid)
|
|
||||||
TextEdit(
|
|
||||||
label = stringResource(id = R.string.app_profile_template_id),
|
|
||||||
text = template.id,
|
|
||||||
errorHint = errorHint,
|
|
||||||
isError = errorHint.isNotEmpty()
|
|
||||||
) { value ->
|
|
||||||
errorHint = if (isTemplateExist(value)) {
|
|
||||||
idConflictError
|
|
||||||
} else if (!isValidTemplateId(value)) {
|
|
||||||
idInvalidError
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
template = template.copy(id = value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TextEdit(
|
|
||||||
label = stringResource(id = R.string.app_profile_template_name),
|
|
||||||
text = template.name
|
|
||||||
) { value ->
|
|
||||||
template.copy(name = value).run {
|
|
||||||
if (autoSave) {
|
|
||||||
if (!saveTemplate(this)) {
|
|
||||||
// failed
|
|
||||||
return@run
|
|
||||||
}
|
|
||||||
}
|
|
||||||
template = this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TextEdit(
|
|
||||||
label = stringResource(id = R.string.app_profile_template_description),
|
|
||||||
text = template.description
|
|
||||||
) { value ->
|
|
||||||
template.copy(description = value).run {
|
|
||||||
if (autoSave) {
|
|
||||||
if (!saveTemplate(this)) {
|
|
||||||
// failed
|
|
||||||
return@run
|
|
||||||
}
|
|
||||||
}
|
|
||||||
template = this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RootProfileConfig(fixedName = true,
|
|
||||||
profile = toNativeProfile(template),
|
|
||||||
onProfileChange = {
|
|
||||||
template.copy(
|
|
||||||
uid = it.uid,
|
|
||||||
gid = it.gid,
|
|
||||||
groups = it.groups,
|
|
||||||
capabilities = it.capabilities,
|
|
||||||
context = it.context,
|
|
||||||
namespace = it.namespace,
|
|
||||||
rules = it.rules.split("\n")
|
|
||||||
).run {
|
|
||||||
if (autoSave) {
|
|
||||||
if (!saveTemplate(this)) {
|
|
||||||
// failed
|
|
||||||
return@run
|
|
||||||
}
|
|
||||||
}
|
|
||||||
template = this
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toNativeProfile(templateInfo: TemplateViewModel.TemplateInfo): Natives.Profile {
|
|
||||||
return Natives.Profile().copy(rootTemplate = templateInfo.id,
|
|
||||||
uid = templateInfo.uid,
|
|
||||||
gid = templateInfo.gid,
|
|
||||||
groups = templateInfo.groups,
|
|
||||||
capabilities = templateInfo.capabilities,
|
|
||||||
context = templateInfo.context,
|
|
||||||
namespace = templateInfo.namespace,
|
|
||||||
rules = templateInfo.rules.joinToString("\n").ifBlank { "" })
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isTemplateValid(template: TemplateViewModel.TemplateInfo): Boolean {
|
|
||||||
if (template.id.isBlank()) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidTemplateId(template.id)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveTemplate(template: TemplateViewModel.TemplateInfo, isCreation: Boolean = false): Boolean {
|
|
||||||
if (!isTemplateValid(template)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCreation && isTemplateExist(template.id)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
val json = template.toJSON()
|
|
||||||
json.put("local", true)
|
|
||||||
return setAppProfileTemplate(template.id, json.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun TopBar(
|
|
||||||
title: String,
|
|
||||||
readOnly: Boolean,
|
|
||||||
summary: String = "",
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onDelete: () -> Unit = {},
|
|
||||||
onSave: () -> Unit = {},
|
|
||||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
|
||||||
) {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Column {
|
|
||||||
Text(title)
|
|
||||||
if (summary.isNotBlank()) {
|
|
||||||
Text(
|
|
||||||
text = summary,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, navigationIcon = {
|
|
||||||
IconButton(
|
|
||||||
onClick = onBack
|
|
||||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
|
||||||
}, actions = {
|
|
||||||
if (readOnly) {
|
|
||||||
return@TopAppBar
|
|
||||||
}
|
|
||||||
IconButton(onClick = onDelete) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.DeleteForever,
|
|
||||||
contentDescription = stringResource(id = R.string.app_profile_template_delete)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(onClick = onSave) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Save,
|
|
||||||
contentDescription = stringResource(id = R.string.app_profile_template_save)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TextEdit(
|
|
||||||
label: String,
|
|
||||||
text: String,
|
|
||||||
errorHint: String = "",
|
|
||||||
isError: Boolean = false,
|
|
||||||
onValueChange: (String) -> Unit = {}
|
|
||||||
) {
|
|
||||||
ListItem(headlineContent = {
|
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
OutlinedTextField(
|
|
||||||
value = text,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
label = { Text(label) },
|
|
||||||
suffix = {
|
|
||||||
if (errorHint.isNotBlank()) {
|
|
||||||
Text(
|
|
||||||
text = if (isError) errorHint else "",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isError = isError,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Next
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(onDone = {
|
|
||||||
keyboardController?.hide()
|
|
||||||
}),
|
|
||||||
onValueChange = onValueChange
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isValidTemplateId(id: String): Boolean {
|
|
||||||
return Regex("""^([A-Za-z][A-Za-z\d_]*\.)*[A-Za-z][A-Za-z\d_]*$""").matches(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isTemplateExist(id: String): Boolean {
|
|
||||||
return getAppProfileTemplate(id).isNotBlank()
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.theme
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.luminance
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
object CardConfig {
|
|
||||||
val settingElevation: Dp = 4.dp
|
|
||||||
val customBackgroundElevation: Dp = 0.dp
|
|
||||||
|
|
||||||
var cardAlpha by mutableStateOf(1f)
|
|
||||||
var cardElevation by mutableStateOf(settingElevation)
|
|
||||||
var isShadowEnabled by mutableStateOf(true)
|
|
||||||
var isCustomAlphaSet by mutableStateOf(false)
|
|
||||||
var isUserDarkModeEnabled by mutableStateOf(false)
|
|
||||||
var isUserLightModeEnabled by mutableStateOf(false)
|
|
||||||
var isCustomBackgroundEnabled by mutableStateOf(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存卡片配置到SharedPreferences
|
|
||||||
*/
|
|
||||||
fun save(context: Context) {
|
|
||||||
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
prefs.edit().apply {
|
|
||||||
putFloat("card_alpha", cardAlpha)
|
|
||||||
putBoolean("custom_background_enabled", isCustomBackgroundEnabled)
|
|
||||||
putBoolean("is_shadow_enabled", isShadowEnabled)
|
|
||||||
putBoolean("is_custom_alpha_set", isCustomAlphaSet)
|
|
||||||
putBoolean("is_user_dark_mode_enabled", isUserDarkModeEnabled)
|
|
||||||
putBoolean("is_user_light_mode_enabled", isUserLightModeEnabled)
|
|
||||||
apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从SharedPreferences加载卡片配置
|
|
||||||
*/
|
|
||||||
fun load(context: Context) {
|
|
||||||
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
cardAlpha = prefs.getFloat("card_alpha", 1f)
|
|
||||||
isCustomBackgroundEnabled = prefs.getBoolean("custom_background_enabled", false)
|
|
||||||
isShadowEnabled = prefs.getBoolean("is_shadow_enabled", true)
|
|
||||||
isCustomAlphaSet = prefs.getBoolean("is_custom_alpha_set", false)
|
|
||||||
isUserDarkModeEnabled = prefs.getBoolean("is_user_dark_mode_enabled", false)
|
|
||||||
isUserLightModeEnabled = prefs.getBoolean("is_user_light_mode_enabled", false)
|
|
||||||
updateShadowEnabled(isShadowEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新阴影启用状态
|
|
||||||
*/
|
|
||||||
fun updateShadowEnabled(enabled: Boolean) {
|
|
||||||
isShadowEnabled = enabled
|
|
||||||
cardElevation = if (isCustomBackgroundEnabled && cardAlpha != 1f) {
|
|
||||||
customBackgroundElevation
|
|
||||||
} else if (enabled) {
|
|
||||||
settingElevation
|
|
||||||
} else {
|
|
||||||
customBackgroundElevation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置深色模式默认值
|
|
||||||
*/
|
|
||||||
fun setDarkModeDefaults() {
|
|
||||||
if (!isCustomAlphaSet) {
|
|
||||||
cardAlpha = 1f
|
|
||||||
}
|
|
||||||
updateShadowEnabled(isShadowEnabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取卡片颜色配置
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun getCardColors(originalColor: Color) = CardDefaults.cardColors(
|
|
||||||
containerColor = originalColor.copy(alpha = CardConfig.cardAlpha),
|
|
||||||
contentColor = determineContentColor(originalColor)
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据背景颜色、主题模式和用户设置确定内容颜色
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun determineContentColor(originalColor: Color): Color {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
if (ThemeConfig.isThemeChanging) {
|
|
||||||
return if (isDarkTheme) Color.White else Color.Black
|
|
||||||
}
|
|
||||||
|
|
||||||
return when {
|
|
||||||
CardConfig.isUserLightModeEnabled -> Color.Black
|
|
||||||
!isDarkTheme && originalColor.luminance() > 0.5f -> Color.Black
|
|
||||||
isDarkTheme -> Color.White
|
|
||||||
else -> if (originalColor.luminance() > 0.5f) Color.Black else Color.White
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.theme
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
sealed class ThemeColors {
|
|
||||||
abstract val Primary: Color
|
|
||||||
abstract val Secondary: Color
|
|
||||||
abstract val Tertiary: Color
|
|
||||||
abstract val OnPrimary: Color
|
|
||||||
abstract val OnSecondary: Color
|
|
||||||
abstract val OnTertiary: Color
|
|
||||||
abstract val PrimaryContainer: Color
|
|
||||||
abstract val SecondaryContainer: Color
|
|
||||||
abstract val TertiaryContainer: Color
|
|
||||||
abstract val OnPrimaryContainer: Color
|
|
||||||
abstract val OnSecondaryContainer: Color
|
|
||||||
abstract val OnTertiaryContainer: Color
|
|
||||||
abstract val ButtonContrast: Color
|
|
||||||
|
|
||||||
// 表面颜色
|
|
||||||
abstract val Surface: Color
|
|
||||||
abstract val SurfaceVariant: Color
|
|
||||||
abstract val OnSurface: Color
|
|
||||||
abstract val OnSurfaceVariant: Color
|
|
||||||
|
|
||||||
// 错误状态颜色
|
|
||||||
abstract val Error: Color
|
|
||||||
abstract val OnError: Color
|
|
||||||
abstract val ErrorContainer: Color
|
|
||||||
abstract val OnErrorContainer: Color
|
|
||||||
|
|
||||||
// 边框和背景色
|
|
||||||
abstract val Outline: Color
|
|
||||||
abstract val OutlineVariant: Color
|
|
||||||
abstract val Background: Color
|
|
||||||
abstract val OnBackground: Color
|
|
||||||
|
|
||||||
// 默认主题 (蓝色)
|
|
||||||
object Default : ThemeColors() {
|
|
||||||
override val Primary = Color(0xFF2196F3)
|
|
||||||
override val Secondary = Color(0xFF64B5F6)
|
|
||||||
override val Tertiary = Color(0xFF0D47A1)
|
|
||||||
override val OnPrimary = Color(0xFFFFFFFF)
|
|
||||||
override val OnSecondary = Color(0xFFFFFFFF)
|
|
||||||
override val OnTertiary = Color(0xFFFFFFFF)
|
|
||||||
override val PrimaryContainer = Color(0xFFD6EAFF)
|
|
||||||
override val SecondaryContainer = Color(0xFFE3F2FD)
|
|
||||||
override val TertiaryContainer = Color(0xFFCFD8DC)
|
|
||||||
override val OnPrimaryContainer = Color(0xFF0A3049)
|
|
||||||
override val OnSecondaryContainer = Color(0xFF0D3C61)
|
|
||||||
override val OnTertiaryContainer = Color(0xFF071D41)
|
|
||||||
override val ButtonContrast = Color(0xFF2196F3)
|
|
||||||
|
|
||||||
override val Surface = Color(0xFFF5F9FF)
|
|
||||||
override val SurfaceVariant = Color(0xFFEDF5FE)
|
|
||||||
override val OnSurface = Color(0xFF1A1C1E)
|
|
||||||
override val OnSurfaceVariant = Color(0xFF42474E)
|
|
||||||
|
|
||||||
override val Error = Color(0xFFB00020)
|
|
||||||
override val OnError = Color(0xFFFFFFFF)
|
|
||||||
override val ErrorContainer = Color(0xFFFDE7E9)
|
|
||||||
override val OnErrorContainer = Color(0xFF410008)
|
|
||||||
|
|
||||||
override val Outline = Color(0xFFBAC3CF)
|
|
||||||
override val OutlineVariant = Color(0xFFDFE3EB)
|
|
||||||
override val Background = Color(0xFFFAFCFF)
|
|
||||||
override val OnBackground = Color(0xFF1A1C1E)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 绿色主题
|
|
||||||
object Green : ThemeColors() {
|
|
||||||
override val Primary = Color(0xFF43A047)
|
|
||||||
override val Secondary = Color(0xFF66BB6A)
|
|
||||||
override val Tertiary = Color(0xFF1B5E20)
|
|
||||||
override val OnPrimary = Color(0xFFFFFFFF)
|
|
||||||
override val OnSecondary = Color(0xFFFFFFFF)
|
|
||||||
override val OnTertiary = Color(0xFFFFFFFF)
|
|
||||||
override val PrimaryContainer = Color(0xFFD8EFDB)
|
|
||||||
override val SecondaryContainer = Color(0xFFE8F5E9)
|
|
||||||
override val TertiaryContainer = Color(0xFFB9F6CA)
|
|
||||||
override val OnPrimaryContainer = Color(0xFF0A280D)
|
|
||||||
override val OnSecondaryContainer = Color(0xFF0E2912)
|
|
||||||
override val OnTertiaryContainer = Color(0xFF051B07)
|
|
||||||
override val ButtonContrast = Color(0xFF43A047)
|
|
||||||
|
|
||||||
override val Surface = Color(0xFFF6FBF6)
|
|
||||||
override val SurfaceVariant = Color(0xFFEDF7EE)
|
|
||||||
override val OnSurface = Color(0xFF191C19)
|
|
||||||
override val OnSurfaceVariant = Color(0xFF414941)
|
|
||||||
|
|
||||||
override val Error = Color(0xFFC62828)
|
|
||||||
override val OnError = Color(0xFFFFFFFF)
|
|
||||||
override val ErrorContainer = Color(0xFFF8D7DA)
|
|
||||||
override val OnErrorContainer = Color(0xFF4A0808)
|
|
||||||
|
|
||||||
override val Outline = Color(0xFFBDC9BF)
|
|
||||||
override val OutlineVariant = Color(0xFFDDE6DE)
|
|
||||||
override val Background = Color(0xFFFBFDFB)
|
|
||||||
override val OnBackground = Color(0xFF191C19)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 紫色主题
|
|
||||||
object Purple : ThemeColors() {
|
|
||||||
override val Primary = Color(0xFF9C27B0)
|
|
||||||
override val Secondary = Color(0xFFBA68C8)
|
|
||||||
override val Tertiary = Color(0xFF6A1B9A)
|
|
||||||
override val OnPrimary = Color(0xFFFFFFFF)
|
|
||||||
override val OnSecondary = Color(0xFFFFFFFF)
|
|
||||||
override val OnTertiary = Color(0xFFFFFFFF)
|
|
||||||
override val PrimaryContainer = Color(0xFFF3D8F8)
|
|
||||||
override val SecondaryContainer = Color(0xFFF5E9F7)
|
|
||||||
override val TertiaryContainer = Color(0xFFE1BEE7)
|
|
||||||
override val OnPrimaryContainer = Color(0xFF2A0934)
|
|
||||||
override val OnSecondaryContainer = Color(0xFF3C0F50)
|
|
||||||
override val OnTertiaryContainer = Color(0xFF1D0830)
|
|
||||||
override val ButtonContrast = Color(0xFF9C27B0)
|
|
||||||
|
|
||||||
override val Surface = Color(0xFFFCF6FF)
|
|
||||||
override val SurfaceVariant = Color(0xFFF5EEFA)
|
|
||||||
override val OnSurface = Color(0xFF1D1B1E)
|
|
||||||
override val OnSurfaceVariant = Color(0xFF49454E)
|
|
||||||
|
|
||||||
override val Error = Color(0xFFD50000)
|
|
||||||
override val OnError = Color(0xFFFFFFFF)
|
|
||||||
override val ErrorContainer = Color(0xFFFFDCD5)
|
|
||||||
override val OnErrorContainer = Color(0xFF480000)
|
|
||||||
|
|
||||||
override val Outline = Color(0xFFC9B9D0)
|
|
||||||
override val OutlineVariant = Color(0xFFE8DAED)
|
|
||||||
override val Background = Color(0xFFFFFBFF)
|
|
||||||
override val OnBackground = Color(0xFF1D1B1E)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 橙色主题
|
|
||||||
object Orange : ThemeColors() {
|
|
||||||
override val Primary = Color(0xFFFF9800)
|
|
||||||
override val Secondary = Color(0xFFFFB74D)
|
|
||||||
override val Tertiary = Color(0xFFE65100)
|
|
||||||
override val OnPrimary = Color(0xFFFFFFFF)
|
|
||||||
override val OnSecondary = Color(0xFF000000)
|
|
||||||
override val OnTertiary = Color(0xFFFFFFFF)
|
|
||||||
override val PrimaryContainer = Color(0xFFFFECCC)
|
|
||||||
override val SecondaryContainer = Color(0xFFFFF0D9)
|
|
||||||
override val TertiaryContainer = Color(0xFFFFD180)
|
|
||||||
override val OnPrimaryContainer = Color(0xFF351F00)
|
|
||||||
override val OnSecondaryContainer = Color(0xFF3D2800)
|
|
||||||
override val OnTertiaryContainer = Color(0xFF2E1500)
|
|
||||||
override val ButtonContrast = Color(0xFFFF9800)
|
|
||||||
|
|
||||||
override val Surface = Color(0xFFFFF8F3)
|
|
||||||
override val SurfaceVariant = Color(0xFFFFF0E6)
|
|
||||||
override val OnSurface = Color(0xFF1F1B16)
|
|
||||||
override val OnSurfaceVariant = Color(0xFF4E4639)
|
|
||||||
|
|
||||||
override val Error = Color(0xFFD32F2F)
|
|
||||||
override val OnError = Color(0xFFFFFFFF)
|
|
||||||
override val ErrorContainer = Color(0xFFFFDBC8)
|
|
||||||
override val OnErrorContainer = Color(0xFF490700)
|
|
||||||
|
|
||||||
override val Outline = Color(0xFFD6C3AD)
|
|
||||||
override val OutlineVariant = Color(0xFFEFDFCC)
|
|
||||||
override val Background = Color(0xFFFFFBFF)
|
|
||||||
override val OnBackground = Color(0xFF1F1B16)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 粉色主题
|
|
||||||
object Pink : ThemeColors() {
|
|
||||||
override val Primary = Color(0xFFE91E63)
|
|
||||||
override val Secondary = Color(0xFFF06292)
|
|
||||||
override val Tertiary = Color(0xFF880E4F)
|
|
||||||
override val OnPrimary = Color(0xFFFFFFFF)
|
|
||||||
override val OnSecondary = Color(0xFFFFFFFF)
|
|
||||||
override val OnTertiary = Color(0xFFFFFFFF)
|
|
||||||
override val PrimaryContainer = Color(0xFFFCE4EC)
|
|
||||||
override val SecondaryContainer = Color(0xFFFCE4EC)
|
|
||||||
override val TertiaryContainer = Color(0xFFF8BBD0)
|
|
||||||
override val OnPrimaryContainer = Color(0xFF3B0819)
|
|
||||||
override val OnSecondaryContainer = Color(0xFF3B0819)
|
|
||||||
override val OnTertiaryContainer = Color(0xFF2B0516)
|
|
||||||
override val ButtonContrast = Color(0xFFE91E63)
|
|
||||||
|
|
||||||
override val Surface = Color(0xFFFFF7F9)
|
|
||||||
override val SurfaceVariant = Color(0xFFFCEEF2)
|
|
||||||
override val OnSurface = Color(0xFF201A1C)
|
|
||||||
override val OnSurfaceVariant = Color(0xFF534347)
|
|
||||||
|
|
||||||
override val Error = Color(0xFFB71C1C)
|
|
||||||
override val OnError = Color(0xFFFFFFFF)
|
|
||||||
override val ErrorContainer = Color(0xFFFFDAD6)
|
|
||||||
override val OnErrorContainer = Color(0xFF410002)
|
|
||||||
|
|
||||||
override val Outline = Color(0xFFD6BABF)
|
|
||||||
override val OutlineVariant = Color(0xFFEFDDE0)
|
|
||||||
override val Background = Color(0xFFFFFBFF)
|
|
||||||
override val OnBackground = Color(0xFF201A1C)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 灰色主题
|
|
||||||
object Gray : ThemeColors() {
|
|
||||||
override val Primary = Color(0xFF607D8B)
|
|
||||||
override val Secondary = Color(0xFF90A4AE)
|
|
||||||
override val Tertiary = Color(0xFF455A64)
|
|
||||||
override val OnPrimary = Color(0xFFFFFFFF)
|
|
||||||
override val OnSecondary = Color(0xFFFFFFFF)
|
|
||||||
override val OnTertiary = Color(0xFFFFFFFF)
|
|
||||||
override val PrimaryContainer = Color(0xFFECEFF1)
|
|
||||||
override val SecondaryContainer = Color(0xFFECEFF1)
|
|
||||||
override val TertiaryContainer = Color(0xFFCFD8DC)
|
|
||||||
override val OnPrimaryContainer = Color(0xFF1A2327)
|
|
||||||
override val OnSecondaryContainer = Color(0xFF1A2327)
|
|
||||||
override val OnTertiaryContainer = Color(0xFF121A1D)
|
|
||||||
override val ButtonContrast = Color(0xFF607D8B)
|
|
||||||
|
|
||||||
override val Surface = Color(0xFFF6F9FB)
|
|
||||||
override val SurfaceVariant = Color(0xFFEEF2F4)
|
|
||||||
override val OnSurface = Color(0xFF191C1E)
|
|
||||||
override val OnSurfaceVariant = Color(0xFF41484D)
|
|
||||||
|
|
||||||
override val Error = Color(0xFFC62828)
|
|
||||||
override val OnError = Color(0xFFFFFFFF)
|
|
||||||
override val ErrorContainer = Color(0xFFFFDAD6)
|
|
||||||
override val OnErrorContainer = Color(0xFF410002)
|
|
||||||
|
|
||||||
override val Outline = Color(0xFFBDC1C4)
|
|
||||||
override val OutlineVariant = Color(0xFFDDE1E3)
|
|
||||||
override val Background = Color(0xFFFBFCFE)
|
|
||||||
override val OnBackground = Color(0xFF191C1E)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 黄色主题
|
|
||||||
object Yellow : ThemeColors() {
|
|
||||||
override val Primary = Color(0xFFFFC107)
|
|
||||||
override val Secondary = Color(0xFFFFD54F)
|
|
||||||
override val Tertiary = Color(0xFFFF8F00)
|
|
||||||
override val OnPrimary = Color(0xFF000000)
|
|
||||||
override val OnSecondary = Color(0xFF000000)
|
|
||||||
override val OnTertiary = Color(0xFFFFFFFF)
|
|
||||||
override val PrimaryContainer = Color(0xFFFFF8E1)
|
|
||||||
override val SecondaryContainer = Color(0xFFFFF8E1)
|
|
||||||
override val TertiaryContainer = Color(0xFFFFECB3)
|
|
||||||
override val OnPrimaryContainer = Color(0xFF332A00)
|
|
||||||
override val OnSecondaryContainer = Color(0xFF332A00)
|
|
||||||
override val OnTertiaryContainer = Color(0xFF221200)
|
|
||||||
override val ButtonContrast = Color(0xFFFFC107)
|
|
||||||
|
|
||||||
override val Surface = Color(0xFFFFFAF3)
|
|
||||||
override val SurfaceVariant = Color(0xFFFFF7E6)
|
|
||||||
override val OnSurface = Color(0xFF1F1C17)
|
|
||||||
override val OnSurfaceVariant = Color(0xFF4E4A3C)
|
|
||||||
|
|
||||||
override val Error = Color(0xFFB71C1C)
|
|
||||||
override val OnError = Color(0xFFFFFFFF)
|
|
||||||
override val ErrorContainer = Color(0xFFFFDAD6)
|
|
||||||
override val OnErrorContainer = Color(0xFF410002)
|
|
||||||
|
|
||||||
override val Outline = Color(0xFFD1C8AF)
|
|
||||||
override val OutlineVariant = Color(0xFFEEE8D7)
|
|
||||||
override val Background = Color(0xFFFFFCF8)
|
|
||||||
override val OnBackground = Color(0xFF1F1C17)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromName(name: String): ThemeColors = when (name.lowercase()) {
|
|
||||||
"green" -> Green
|
|
||||||
"purple" -> Purple
|
|
||||||
"orange" -> Orange
|
|
||||||
"pink" -> Pink
|
|
||||||
"gray" -> Gray
|
|
||||||
"yellow" -> Yellow
|
|
||||||
else -> Default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,538 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.theme
|
|
||||||
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.spring
|
|
||||||
import androidx.compose.animation.core.updateTransition
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.paint
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import coil.compose.AsyncImagePainter
|
|
||||||
import coil.compose.rememberAsyncImagePainter
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.InputStream
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import com.sukisu.ultra.ui.util.BackgroundTransformation
|
|
||||||
import com.sukisu.ultra.ui.util.saveTransformedBackground
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 主题配置对象,管理应用的主题相关状态
|
|
||||||
*/
|
|
||||||
object ThemeConfig {
|
|
||||||
var customBackgroundUri by mutableStateOf<Uri?>(null)
|
|
||||||
var forceDarkMode by mutableStateOf<Boolean?>(null)
|
|
||||||
var currentTheme by mutableStateOf<ThemeColors>(ThemeColors.Default)
|
|
||||||
var useDynamicColor by mutableStateOf(false)
|
|
||||||
var backgroundImageLoaded by mutableStateOf(false)
|
|
||||||
var needsResetOnThemeChange by mutableStateOf(false)
|
|
||||||
var isThemeChanging by mutableStateOf(false)
|
|
||||||
var preventBackgroundRefresh by mutableStateOf(false)
|
|
||||||
|
|
||||||
private var lastDarkModeState: Boolean? = null
|
|
||||||
fun detectThemeChange(currentDarkMode: Boolean): Boolean {
|
|
||||||
val isChanged = lastDarkModeState != null && lastDarkModeState != currentDarkMode
|
|
||||||
lastDarkModeState = currentDarkMode
|
|
||||||
return isChanged
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetBackgroundState() {
|
|
||||||
if (!preventBackgroundRefresh) {
|
|
||||||
backgroundImageLoaded = false
|
|
||||||
}
|
|
||||||
isThemeChanging = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用主题
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun KernelSUTheme(
|
|
||||||
darkTheme: Boolean = when(ThemeConfig.forceDarkMode) {
|
|
||||||
true -> true
|
|
||||||
false -> false
|
|
||||||
null -> isSystemInDarkTheme()
|
|
||||||
},
|
|
||||||
dynamicColor: Boolean = ThemeConfig.useDynamicColor,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val systemIsDark = isSystemInDarkTheme()
|
|
||||||
|
|
||||||
// 检测系统主题变化并保存状态
|
|
||||||
val themeChanged = ThemeConfig.detectThemeChange(systemIsDark)
|
|
||||||
LaunchedEffect(systemIsDark, themeChanged) {
|
|
||||||
if (ThemeConfig.forceDarkMode == null && themeChanged) {
|
|
||||||
Log.d("ThemeSystem", "系统主题变化检测: 从 ${!systemIsDark} 变为 $systemIsDark")
|
|
||||||
ThemeConfig.resetBackgroundState()
|
|
||||||
|
|
||||||
if (!ThemeConfig.preventBackgroundRefresh) {
|
|
||||||
context.loadCustomBackground()
|
|
||||||
}
|
|
||||||
|
|
||||||
CardConfig.apply {
|
|
||||||
load(context)
|
|
||||||
if (!isCustomAlphaSet) {
|
|
||||||
cardAlpha = if (systemIsDark) 0.50f else 1f
|
|
||||||
}
|
|
||||||
save(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始加载配置
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
context.loadThemeMode()
|
|
||||||
context.loadThemeColors()
|
|
||||||
context.loadDynamicColorState()
|
|
||||||
CardConfig.load(context)
|
|
||||||
|
|
||||||
if (!ThemeConfig.backgroundImageLoaded && !ThemeConfig.preventBackgroundRefresh) {
|
|
||||||
context.loadCustomBackground()
|
|
||||||
ThemeConfig.backgroundImageLoaded = false
|
|
||||||
}
|
|
||||||
|
|
||||||
ThemeConfig.preventBackgroundRefresh = context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
|
||||||
.getBoolean("prevent_background_refresh", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建颜色方案
|
|
||||||
val colorScheme = when {
|
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
|
||||||
if (darkTheme) createDynamicDarkColorScheme(context) else createDynamicLightColorScheme(context)
|
|
||||||
}
|
|
||||||
darkTheme -> createDarkColorScheme()
|
|
||||||
else -> createLightColorScheme()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据暗色模式和自定义背景调整卡片配置
|
|
||||||
val isDarkModeWithCustomBackground = darkTheme && ThemeConfig.customBackgroundUri != null
|
|
||||||
if (darkTheme && !dynamicColor) {
|
|
||||||
CardConfig.setDarkModeDefaults()
|
|
||||||
}
|
|
||||||
CardConfig.updateShadowEnabled(!isDarkModeWithCustomBackground)
|
|
||||||
|
|
||||||
val backgroundUri = rememberSaveable { mutableStateOf(ThemeConfig.customBackgroundUri) }
|
|
||||||
|
|
||||||
LaunchedEffect(ThemeConfig.customBackgroundUri) {
|
|
||||||
backgroundUri.value = ThemeConfig.customBackgroundUri
|
|
||||||
}
|
|
||||||
|
|
||||||
val bgImagePainter = backgroundUri.value?.let {
|
|
||||||
rememberAsyncImagePainter(
|
|
||||||
model = it,
|
|
||||||
onError = {
|
|
||||||
Log.e("ThemeSystem", "背景图加载失败: ${it.result.throwable.message}")
|
|
||||||
ThemeConfig.customBackgroundUri = null
|
|
||||||
context.saveCustomBackground(null)
|
|
||||||
},
|
|
||||||
onSuccess = {
|
|
||||||
Log.d("ThemeSystem", "背景图加载成功")
|
|
||||||
ThemeConfig.backgroundImageLoaded = true
|
|
||||||
ThemeConfig.isThemeChanging = false
|
|
||||||
|
|
||||||
ThemeConfig.preventBackgroundRefresh = true
|
|
||||||
context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
|
||||||
.edit { putBoolean("prevent_background_refresh", true) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val transition = updateTransition(
|
|
||||||
targetState = ThemeConfig.backgroundImageLoaded,
|
|
||||||
label = "bgTransition"
|
|
||||||
)
|
|
||||||
val bgAlpha by transition.animateFloat(
|
|
||||||
label = "bgAlpha",
|
|
||||||
transitionSpec = {
|
|
||||||
spring(
|
|
||||||
dampingRatio = 0.8f,
|
|
||||||
stiffness = 300f
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { loaded -> if (loaded) 1f else 0f }
|
|
||||||
|
|
||||||
DisposableEffect(systemIsDark) {
|
|
||||||
onDispose {
|
|
||||||
if (ThemeConfig.isThemeChanging) {
|
|
||||||
ThemeConfig.isThemeChanging = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
typography = Typography
|
|
||||||
) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.zIndex(-2f)
|
|
||||||
.background(if (darkTheme) Color.Black else Color.White)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 自定义背景层
|
|
||||||
backgroundUri.value?.let { uri ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.zIndex(-1f)
|
|
||||||
.alpha(bgAlpha)
|
|
||||||
) {
|
|
||||||
// 背景图片
|
|
||||||
bgImagePainter?.let { painter ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.paint(
|
|
||||||
painter = painter,
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
.graphicsLayer {
|
|
||||||
alpha = (painter.state as? AsyncImagePainter.State.Success)?.let { 1f } ?: 0f
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 亮度调节层
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(
|
|
||||||
if (darkTheme) Color.Black.copy(alpha = 0.6f)
|
|
||||||
else Color.White.copy(alpha = 0.1f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 边缘渐变遮罩
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(
|
|
||||||
Brush.radialGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.Transparent,
|
|
||||||
if (darkTheme) Color.Black.copy(alpha = 0.5f)
|
|
||||||
else Color.Black.copy(alpha = 0.2f)
|
|
||||||
),
|
|
||||||
radius = 1200f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内容层
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.zIndex(1f)
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建动态深色颜色方案
|
|
||||||
*/
|
|
||||||
@RequiresApi(Build.VERSION_CODES.S)
|
|
||||||
@Composable
|
|
||||||
private fun createDynamicDarkColorScheme(context: Context) =
|
|
||||||
dynamicDarkColorScheme(context).copy(
|
|
||||||
background = Color.Transparent,
|
|
||||||
surface = Color.Transparent,
|
|
||||||
onBackground = Color.White,
|
|
||||||
onSurface = Color.White
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建动态浅色颜色方案
|
|
||||||
*/
|
|
||||||
@RequiresApi(Build.VERSION_CODES.S)
|
|
||||||
@Composable
|
|
||||||
private fun createDynamicLightColorScheme(context: Context) =
|
|
||||||
dynamicLightColorScheme(context).copy(
|
|
||||||
background = Color.Transparent,
|
|
||||||
surface = Color.Transparent
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建深色颜色方案
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun createDarkColorScheme() = darkColorScheme(
|
|
||||||
primary = ThemeConfig.currentTheme.Primary.copy(alpha = 0.8f),
|
|
||||||
onPrimary = Color.White,
|
|
||||||
primaryContainer = ThemeConfig.currentTheme.PrimaryContainer.copy(alpha = 0.15f),
|
|
||||||
onPrimaryContainer = Color.White,
|
|
||||||
secondary = ThemeConfig.currentTheme.Secondary.copy(alpha = 0.8f),
|
|
||||||
onSecondary = Color.White,
|
|
||||||
secondaryContainer = ThemeConfig.currentTheme.SecondaryContainer.copy(alpha = 0.15f),
|
|
||||||
onSecondaryContainer = Color.White,
|
|
||||||
tertiary = ThemeConfig.currentTheme.Tertiary.copy(alpha = 0.8f),
|
|
||||||
onTertiary = Color.White,
|
|
||||||
tertiaryContainer = ThemeConfig.currentTheme.TertiaryContainer.copy(alpha = 0.15f),
|
|
||||||
onTertiaryContainer = Color.White,
|
|
||||||
background = Color.Transparent,
|
|
||||||
surface = Color.Transparent,
|
|
||||||
onBackground = Color.White,
|
|
||||||
onSurface = Color.White,
|
|
||||||
surfaceVariant = Color(0xFF2F2F2F),
|
|
||||||
onSurfaceVariant = Color.White.copy(alpha = 0.7f),
|
|
||||||
outline = Color.White.copy(alpha = 0.12f),
|
|
||||||
outlineVariant = Color.White.copy(alpha = 0.12f),
|
|
||||||
error = ThemeConfig.currentTheme.Error,
|
|
||||||
onError = ThemeConfig.currentTheme.OnError,
|
|
||||||
errorContainer = ThemeConfig.currentTheme.ErrorContainer.copy(alpha = 0.15f),
|
|
||||||
onErrorContainer = Color.White
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建浅色颜色方案
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun createLightColorScheme() = lightColorScheme(
|
|
||||||
primary = ThemeConfig.currentTheme.Primary,
|
|
||||||
onPrimary = ThemeConfig.currentTheme.OnPrimary,
|
|
||||||
primaryContainer = ThemeConfig.currentTheme.PrimaryContainer,
|
|
||||||
onPrimaryContainer = ThemeConfig.currentTheme.OnPrimaryContainer,
|
|
||||||
secondary = ThemeConfig.currentTheme.Secondary,
|
|
||||||
onSecondary = ThemeConfig.currentTheme.OnSecondary,
|
|
||||||
secondaryContainer = ThemeConfig.currentTheme.SecondaryContainer,
|
|
||||||
onSecondaryContainer = ThemeConfig.currentTheme.OnSecondaryContainer,
|
|
||||||
tertiary = ThemeConfig.currentTheme.Tertiary,
|
|
||||||
onTertiary = ThemeConfig.currentTheme.OnTertiary,
|
|
||||||
tertiaryContainer = ThemeConfig.currentTheme.TertiaryContainer,
|
|
||||||
onTertiaryContainer = ThemeConfig.currentTheme.OnTertiaryContainer,
|
|
||||||
background = Color.Transparent,
|
|
||||||
surface = Color.Transparent,
|
|
||||||
onBackground = Color.Black.copy(alpha = 0.87f),
|
|
||||||
onSurface = Color.Black.copy(alpha = 0.87f),
|
|
||||||
surfaceVariant = Color(0xFFF5F5F5),
|
|
||||||
onSurfaceVariant = Color.Black.copy(alpha = 0.78f),
|
|
||||||
outline = Color.Black.copy(alpha = 0.12f),
|
|
||||||
outlineVariant = Color.Black.copy(alpha = 0.12f),
|
|
||||||
error = ThemeConfig.currentTheme.Error,
|
|
||||||
onError = ThemeConfig.currentTheme.OnError,
|
|
||||||
errorContainer = ThemeConfig.currentTheme.ErrorContainer,
|
|
||||||
onErrorContainer = ThemeConfig.currentTheme.OnErrorContainer
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 复制图片到应用内部存储并提升持久性
|
|
||||||
*/
|
|
||||||
private fun Context.copyImageToInternalStorage(uri: Uri): Uri? {
|
|
||||||
return try {
|
|
||||||
val contentResolver: ContentResolver = contentResolver
|
|
||||||
val inputStream: InputStream = contentResolver.openInputStream(uri) ?: return null
|
|
||||||
|
|
||||||
val fileName = "custom_background.jpg"
|
|
||||||
val file = File(filesDir, fileName)
|
|
||||||
|
|
||||||
val backupFile = File(filesDir, "${fileName}.backup")
|
|
||||||
val outputStream = FileOutputStream(backupFile)
|
|
||||||
val buffer = ByteArray(4 * 1024)
|
|
||||||
var read: Int
|
|
||||||
|
|
||||||
while (inputStream.read(buffer).also { read = it } != -1) {
|
|
||||||
outputStream.write(buffer, 0, read)
|
|
||||||
}
|
|
||||||
|
|
||||||
outputStream.flush()
|
|
||||||
outputStream.close()
|
|
||||||
inputStream.close()
|
|
||||||
|
|
||||||
if (file.exists()) {
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
backupFile.renameTo(file)
|
|
||||||
|
|
||||||
Uri.fromFile(file)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("ImageCopy", "复制图片失败: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存并应用自定义背景
|
|
||||||
*/
|
|
||||||
fun Context.saveAndApplyCustomBackground(uri: Uri, transformation: BackgroundTransformation? = null) {
|
|
||||||
val finalUri = if (transformation != null) {
|
|
||||||
saveTransformedBackground(uri, transformation)
|
|
||||||
} else {
|
|
||||||
copyImageToInternalStorage(uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存到配置文件
|
|
||||||
getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
|
||||||
.edit {
|
|
||||||
putString("custom_background", finalUri?.toString())
|
|
||||||
// 设置阻止刷新标志为false,允许新设置的背景加载一次
|
|
||||||
putBoolean("prevent_background_refresh", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
ThemeConfig.customBackgroundUri = finalUri
|
|
||||||
ThemeConfig.backgroundImageLoaded = false
|
|
||||||
ThemeConfig.preventBackgroundRefresh = false
|
|
||||||
CardConfig.cardElevation = 0.dp
|
|
||||||
CardConfig.isCustomBackgroundEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存自定义背景
|
|
||||||
*/
|
|
||||||
fun Context.saveCustomBackground(uri: Uri?) {
|
|
||||||
val newUri = uri?.let { copyImageToInternalStorage(it) }
|
|
||||||
|
|
||||||
// 保存到配置文件
|
|
||||||
getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
|
||||||
.edit {
|
|
||||||
putString("custom_background", newUri?.toString())
|
|
||||||
if (uri == null) {
|
|
||||||
// 如果清除背景,也重置阻止刷新标志
|
|
||||||
putBoolean("prevent_background_refresh", false)
|
|
||||||
} else {
|
|
||||||
// 设置阻止刷新标志为false,允许新设置的背景加载一次
|
|
||||||
putBoolean("prevent_background_refresh", false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ThemeConfig.customBackgroundUri = newUri
|
|
||||||
ThemeConfig.backgroundImageLoaded = false
|
|
||||||
ThemeConfig.preventBackgroundRefresh = false
|
|
||||||
|
|
||||||
if (uri != null) {
|
|
||||||
CardConfig.cardElevation = 0.dp
|
|
||||||
CardConfig.isCustomBackgroundEnabled = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载自定义背景
|
|
||||||
*/
|
|
||||||
fun Context.loadCustomBackground() {
|
|
||||||
val uriString = getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
|
||||||
.getString("custom_background", null)
|
|
||||||
|
|
||||||
val newUri = uriString?.toUri()
|
|
||||||
val preventRefresh = getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
|
||||||
.getBoolean("prevent_background_refresh", false)
|
|
||||||
|
|
||||||
ThemeConfig.preventBackgroundRefresh = preventRefresh
|
|
||||||
|
|
||||||
if (!preventRefresh || ThemeConfig.customBackgroundUri?.toString() != newUri?.toString()) {
|
|
||||||
Log.d("ThemeSystem", "加载自定义背景: $uriString, 阻止刷新: $preventRefresh")
|
|
||||||
ThemeConfig.customBackgroundUri = newUri
|
|
||||||
ThemeConfig.backgroundImageLoaded = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存主题模式
|
|
||||||
*/
|
|
||||||
fun Context.saveThemeMode(forceDark: Boolean?) {
|
|
||||||
getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
|
||||||
.edit {
|
|
||||||
putString(
|
|
||||||
"theme_mode", when (forceDark) {
|
|
||||||
true -> "dark"
|
|
||||||
false -> "light"
|
|
||||||
null -> "system"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ThemeConfig.forceDarkMode = forceDark
|
|
||||||
ThemeConfig.needsResetOnThemeChange = forceDark == null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载主题模式
|
|
||||||
*/
|
|
||||||
fun Context.loadThemeMode() {
|
|
||||||
val mode = getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
|
||||||
.getString("theme_mode", "system")
|
|
||||||
|
|
||||||
ThemeConfig.forceDarkMode = when(mode) {
|
|
||||||
"dark" -> true
|
|
||||||
"light" -> false
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
ThemeConfig.needsResetOnThemeChange = ThemeConfig.forceDarkMode == null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存主题颜色
|
|
||||||
*/
|
|
||||||
fun Context.saveThemeColors(themeName: String) {
|
|
||||||
getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
|
||||||
.edit {
|
|
||||||
putString("theme_colors", themeName)
|
|
||||||
}
|
|
||||||
|
|
||||||
ThemeConfig.currentTheme = ThemeColors.fromName(themeName)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载主题颜色
|
|
||||||
*/
|
|
||||||
fun Context.loadThemeColors() {
|
|
||||||
val themeName = getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
|
||||||
.getString("theme_colors", "default")
|
|
||||||
|
|
||||||
ThemeConfig.currentTheme = ThemeColors.fromName(themeName ?: "default")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存动态颜色状态
|
|
||||||
*/
|
|
||||||
fun Context.saveDynamicColorState(enabled: Boolean) {
|
|
||||||
getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
|
||||||
.edit {
|
|
||||||
putBoolean("use_dynamic_color", enabled)
|
|
||||||
}
|
|
||||||
ThemeConfig.useDynamicColor = enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载动态颜色状态
|
|
||||||
*/
|
|
||||||
fun Context.loadDynamicColorState() {
|
|
||||||
val enabled = getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
|
||||||
.getBoolean("use_dynamic_color", true)
|
|
||||||
|
|
||||||
ThemeConfig.useDynamicColor = enabled
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.theme
|
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
val Typography = Typography(
|
|
||||||
// 大标题
|
|
||||||
displayLarge = TextStyle(
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 57.sp,
|
|
||||||
lineHeight = 64.sp,
|
|
||||||
letterSpacing = (-0.25).sp
|
|
||||||
),
|
|
||||||
displayMedium = TextStyle(
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 45.sp,
|
|
||||||
lineHeight = 52.sp,
|
|
||||||
letterSpacing = 0.sp
|
|
||||||
),
|
|
||||||
displaySmall = TextStyle(
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 36.sp,
|
|
||||||
lineHeight = 44.sp,
|
|
||||||
letterSpacing = 0.sp
|
|
||||||
),
|
|
||||||
|
|
||||||
// 标题
|
|
||||||
headlineLarge = TextStyle(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
fontSize = 32.sp,
|
|
||||||
lineHeight = 40.sp,
|
|
||||||
letterSpacing = 0.sp
|
|
||||||
),
|
|
||||||
headlineMedium = TextStyle(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
fontSize = 28.sp,
|
|
||||||
lineHeight = 36.sp,
|
|
||||||
letterSpacing = 0.sp
|
|
||||||
),
|
|
||||||
headlineSmall = TextStyle(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
fontSize = 24.sp,
|
|
||||||
lineHeight = 32.sp,
|
|
||||||
letterSpacing = 0.sp
|
|
||||||
),
|
|
||||||
|
|
||||||
// 标题栏
|
|
||||||
titleLarge = TextStyle(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
fontSize = 22.sp,
|
|
||||||
lineHeight = 28.sp,
|
|
||||||
letterSpacing = 0.sp
|
|
||||||
),
|
|
||||||
titleMedium = TextStyle(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
lineHeight = 24.sp,
|
|
||||||
letterSpacing = 0.15.sp
|
|
||||||
),
|
|
||||||
titleSmall = TextStyle(
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
lineHeight = 20.sp,
|
|
||||||
letterSpacing = 0.1.sp
|
|
||||||
),
|
|
||||||
|
|
||||||
// 主体文字
|
|
||||||
bodyLarge = TextStyle(
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
lineHeight = 24.sp,
|
|
||||||
letterSpacing = 0.5.sp
|
|
||||||
),
|
|
||||||
bodyMedium = TextStyle(
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
lineHeight = 20.sp,
|
|
||||||
letterSpacing = 0.25.sp
|
|
||||||
),
|
|
||||||
bodySmall = TextStyle(
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
lineHeight = 16.sp,
|
|
||||||
letterSpacing = 0.4.sp
|
|
||||||
),
|
|
||||||
|
|
||||||
// 标签
|
|
||||||
labelLarge = TextStyle(
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
lineHeight = 20.sp,
|
|
||||||
letterSpacing = 0.1.sp
|
|
||||||
),
|
|
||||||
labelMedium = TextStyle(
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
lineHeight = 16.sp,
|
|
||||||
letterSpacing = 0.5.sp
|
|
||||||
),
|
|
||||||
labelSmall = TextStyle(
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
lineHeight = 16.sp,
|
|
||||||
letterSpacing = 0.5.sp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.util
|
|
||||||
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Matrix
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.InputStream
|
|
||||||
import androidx.core.graphics.createBitmap
|
|
||||||
|
|
||||||
data class BackgroundTransformation(
|
|
||||||
val scale: Float = 1f,
|
|
||||||
val offsetX: Float = 0f,
|
|
||||||
val offsetY: Float = 0f
|
|
||||||
)
|
|
||||||
|
|
||||||
fun Context.getImageBitmap(uri: Uri): Bitmap? {
|
|
||||||
return try {
|
|
||||||
val contentResolver: ContentResolver = contentResolver
|
|
||||||
val inputStream: InputStream = contentResolver.openInputStream(uri) ?: return null
|
|
||||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
|
||||||
inputStream.close()
|
|
||||||
bitmap
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("BackgroundUtils", "Failed to get image bitmap: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Context.applyTransformationToBitmap(bitmap: Bitmap, transformation: BackgroundTransformation): Bitmap {
|
|
||||||
val width = bitmap.width
|
|
||||||
val height = bitmap.height
|
|
||||||
|
|
||||||
// 创建与屏幕比例相同的目标位图
|
|
||||||
val displayMetrics = resources.displayMetrics
|
|
||||||
val screenWidth = displayMetrics.widthPixels
|
|
||||||
val screenHeight = displayMetrics.heightPixels
|
|
||||||
val screenRatio = screenHeight.toFloat() / screenWidth.toFloat()
|
|
||||||
|
|
||||||
// 计算目标宽高
|
|
||||||
val targetWidth: Int
|
|
||||||
val targetHeight: Int
|
|
||||||
if (width.toFloat() / height.toFloat() > screenRatio) {
|
|
||||||
targetHeight = height
|
|
||||||
targetWidth = (height / screenRatio).toInt()
|
|
||||||
} else {
|
|
||||||
targetWidth = width
|
|
||||||
targetHeight = (width * screenRatio).toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建与目标相同大小的位图
|
|
||||||
val scaledBitmap = createBitmap(targetWidth, targetHeight)
|
|
||||||
val canvas = Canvas(scaledBitmap)
|
|
||||||
|
|
||||||
val matrix = Matrix()
|
|
||||||
|
|
||||||
// 确保缩放值有效
|
|
||||||
val safeScale = maxOf(0.1f, transformation.scale)
|
|
||||||
matrix.postScale(safeScale, safeScale)
|
|
||||||
|
|
||||||
// 计算偏移量,确保不会出现负最大值的问题
|
|
||||||
val widthDiff = (bitmap.width * safeScale - targetWidth)
|
|
||||||
val heightDiff = (bitmap.height * safeScale - targetHeight)
|
|
||||||
|
|
||||||
// 安全计算偏移量边界
|
|
||||||
val maxOffsetX = maxOf(0f, widthDiff / 2)
|
|
||||||
val maxOffsetY = maxOf(0f, heightDiff / 2)
|
|
||||||
|
|
||||||
// 限制偏移范围
|
|
||||||
val safeOffsetX = if (maxOffsetX > 0)
|
|
||||||
transformation.offsetX.coerceIn(-maxOffsetX, maxOffsetX) else 0f
|
|
||||||
val safeOffsetY = if (maxOffsetY > 0)
|
|
||||||
transformation.offsetY.coerceIn(-maxOffsetY, maxOffsetY) else 0f
|
|
||||||
|
|
||||||
// 应用偏移量到矩阵
|
|
||||||
val translationX = -widthDiff / 2 + safeOffsetX
|
|
||||||
val translationY = -heightDiff / 2 + safeOffsetY
|
|
||||||
|
|
||||||
matrix.postTranslate(translationX, translationY)
|
|
||||||
|
|
||||||
// 将原始位图绘制到新位图上
|
|
||||||
canvas.drawBitmap(bitmap, matrix, null)
|
|
||||||
|
|
||||||
return scaledBitmap
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Context.saveTransformedBackground(uri: Uri, transformation: BackgroundTransformation): Uri? {
|
|
||||||
try {
|
|
||||||
val bitmap = getImageBitmap(uri) ?: return null
|
|
||||||
val transformedBitmap = applyTransformationToBitmap(bitmap, transformation)
|
|
||||||
|
|
||||||
val fileName = "custom_background_transformed.jpg"
|
|
||||||
val file = File(filesDir, fileName)
|
|
||||||
val outputStream = FileOutputStream(file)
|
|
||||||
|
|
||||||
transformedBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
|
|
||||||
outputStream.flush()
|
|
||||||
outputStream.close()
|
|
||||||
|
|
||||||
return Uri.fromFile(file)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("BackgroundUtils", "Failed to save transformed image: ${e.message}", e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.util
|
|
||||||
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
|
|
||||||
val LocalSnackbarHost = compositionLocalOf<SnackbarHostState> {
|
|
||||||
error("CompositionLocal LocalSnackbarController not present")
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.util
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Environment
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.sukisu.ultra.ui.util.module.LatestVersionInfo
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author weishu
|
|
||||||
* @date 2023/6/22.
|
|
||||||
*/
|
|
||||||
@SuppressLint("Range")
|
|
||||||
fun download(
|
|
||||||
context: Context,
|
|
||||||
url: String,
|
|
||||||
fileName: String,
|
|
||||||
description: String,
|
|
||||||
onDownloaded: (Uri) -> Unit = {},
|
|
||||||
onDownloading: () -> Unit = {}
|
|
||||||
) {
|
|
||||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
|
||||||
|
|
||||||
val query = DownloadManager.Query()
|
|
||||||
query.setFilterByStatus(DownloadManager.STATUS_RUNNING or DownloadManager.STATUS_PAUSED or DownloadManager.STATUS_PENDING)
|
|
||||||
downloadManager.query(query).use { cursor ->
|
|
||||||
while (cursor.moveToNext()) {
|
|
||||||
val uri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_URI))
|
|
||||||
val localUri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))
|
|
||||||
val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
|
|
||||||
val columnTitle = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_TITLE))
|
|
||||||
if (url == uri || fileName == columnTitle) {
|
|
||||||
if (status == DownloadManager.STATUS_RUNNING || status == DownloadManager.STATUS_PENDING) {
|
|
||||||
onDownloading()
|
|
||||||
return
|
|
||||||
} else if (status == DownloadManager.STATUS_SUCCESSFUL) {
|
|
||||||
onDownloaded(localUri.toUri())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val request = DownloadManager.Request(url.toUri())
|
|
||||||
.setDestinationInExternalPublicDir(
|
|
||||||
Environment.DIRECTORY_DOWNLOADS,
|
|
||||||
fileName
|
|
||||||
)
|
|
||||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
|
||||||
.setMimeType("application/zip")
|
|
||||||
.setTitle(fileName)
|
|
||||||
.setDescription(description)
|
|
||||||
|
|
||||||
downloadManager.enqueue(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun checkNewVersion(): LatestVersionInfo {
|
|
||||||
val url = "https://api.github.com/repos/ShirkNeko/SukiSU-Ultra/releases/latest"
|
|
||||||
val defaultValue = LatestVersionInfo()
|
|
||||||
return runCatching {
|
|
||||||
okhttp3.OkHttpClient().newCall(okhttp3.Request.Builder().url(url).build()).execute()
|
|
||||||
.use { response ->
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
Log.d("CheckUpdate", "Network request failed: ${response.message}")
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
val body = response.body?.string()
|
|
||||||
if (body == null) {
|
|
||||||
Log.d("CheckUpdate", "Response body is null")
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
Log.d("CheckUpdate", "Response body: $body")
|
|
||||||
val json = org.json.JSONObject(body)
|
|
||||||
|
|
||||||
// 直接从 tag_name 提取版本号(如 v1.1)
|
|
||||||
val tagName = json.optString("tag_name", "")
|
|
||||||
val versionName = tagName.removePrefix("v") // 移除前缀 "v"
|
|
||||||
|
|
||||||
// 从 body 字段获取更新日志(保留换行符)
|
|
||||||
val changelog = json.optString("body")
|
|
||||||
.replace("\\r\\n", "\n") // 转换换行符
|
|
||||||
|
|
||||||
val assets = json.getJSONArray("assets")
|
|
||||||
for (i in 0 until assets.length()) {
|
|
||||||
val asset = assets.getJSONObject(i)
|
|
||||||
val name = asset.getString("name")
|
|
||||||
if (!name.endsWith(".apk")) continue
|
|
||||||
|
|
||||||
// 修改正则表达式,只匹配 SukiSU 和版本号
|
|
||||||
val regex = Regex("SukiSU.*_(\\d+)-release")
|
|
||||||
val matchResult = regex.find(name)
|
|
||||||
if (matchResult == null) {
|
|
||||||
Log.d("CheckUpdate", "No match found in $name, skipping")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val versionCode = matchResult.groupValues[1].toInt()
|
|
||||||
|
|
||||||
val downloadUrl = asset.getString("browser_download_url")
|
|
||||||
return LatestVersionInfo(
|
|
||||||
versionCode,
|
|
||||||
downloadUrl,
|
|
||||||
changelog,
|
|
||||||
versionName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Log.d("CheckUpdate", "No valid apk asset found, returning default value")
|
|
||||||
defaultValue
|
|
||||||
}
|
|
||||||
}.getOrDefault(defaultValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DownloadListener(context: Context, onDownloaded: (Uri) -> Unit) {
|
|
||||||
DisposableEffect(context) {
|
|
||||||
val receiver = object : BroadcastReceiver() {
|
|
||||||
@SuppressLint("Range")
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
if (intent?.action == DownloadManager.ACTION_DOWNLOAD_COMPLETE) {
|
|
||||||
val id = intent.getLongExtra(
|
|
||||||
DownloadManager.EXTRA_DOWNLOAD_ID, -1
|
|
||||||
)
|
|
||||||
val query = DownloadManager.Query().setFilterById(id)
|
|
||||||
val downloadManager =
|
|
||||||
context?.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
|
||||||
val cursor = downloadManager.query(query)
|
|
||||||
if (cursor.moveToFirst()) {
|
|
||||||
val status = cursor.getInt(
|
|
||||||
cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
|
||||||
)
|
|
||||||
if (status == DownloadManager.STATUS_SUCCESSFUL) {
|
|
||||||
val uri = cursor.getString(
|
|
||||||
cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
|
|
||||||
)
|
|
||||||
onDownloaded(uri.toUri())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ContextCompat.registerReceiver(
|
|
||||||
context,
|
|
||||||
receiver,
|
|
||||||
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
|
|
||||||
ContextCompat.RECEIVER_EXPORTED
|
|
||||||
)
|
|
||||||
onDispose {
|
|
||||||
context.unregisterReceiver(receiver)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,576 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.util;
|
|
||||||
/*
|
|
||||||
* Copyright (C) 2009 The Android Open Source Project
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import android.text.TextUtils;
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import java.text.Collator;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object to convert Chinese character to its corresponding pinyin string. For characters with
|
|
||||||
* multiple possible pinyin string, only one is selected according to collator. Polyphone is not
|
|
||||||
* supported in this implementation. This class is implemented to achieve the best runtime
|
|
||||||
* performance and minimum runtime resources with tolerable sacrifice of accuracy. This
|
|
||||||
* implementation highly depends on zh_CN ICU collation data and must be always synchronized with
|
|
||||||
* ICU.
|
|
||||||
* <p>
|
|
||||||
* Currently this file is aligned to zh.txt in ICU 4.6
|
|
||||||
*/
|
|
||||||
public class HanziToPinyin {
|
|
||||||
private static final String TAG = "HanziToPinyin";
|
|
||||||
|
|
||||||
// Turn on this flag when we want to check internal data structure.
|
|
||||||
private static final boolean DEBUG = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unihans array.
|
|
||||||
* <p>
|
|
||||||
* Each unihans is the first one within same pinyin when collator is zh_CN.
|
|
||||||
*/
|
|
||||||
public static final char[] UNIHANS = {
|
|
||||||
'\u963f', '\u54ce', '\u5b89', '\u80ae', '\u51f9', '\u516b',
|
|
||||||
'\u6300', '\u6273', '\u90a6', '\u52f9', '\u9642', '\u5954',
|
|
||||||
'\u4f3b', '\u5c44', '\u8fb9', '\u706c', '\u618b', '\u6c43',
|
|
||||||
'\u51ab', '\u7676', '\u5cec', '\u5693', '\u5072', '\u53c2',
|
|
||||||
'\u4ed3', '\u64a1', '\u518a', '\u5d7e', '\u66fd', '\u66fe',
|
|
||||||
'\u5c64', '\u53c9', '\u8286', '\u8fbf', '\u4f25', '\u6284',
|
|
||||||
'\u8f66', '\u62bb', '\u6c88', '\u6c89', '\u9637', '\u5403',
|
|
||||||
'\u5145', '\u62bd', '\u51fa', '\u6b3b', '\u63e3', '\u5ddb',
|
|
||||||
'\u5205', '\u5439', '\u65fe', '\u9034', '\u5472', '\u5306',
|
|
||||||
'\u51d1', '\u7c97', '\u6c46', '\u5d14', '\u90a8', '\u6413',
|
|
||||||
'\u5491', '\u5446', '\u4e39', '\u5f53', '\u5200', '\u561a',
|
|
||||||
'\u6265', '\u706f', '\u6c10', '\u55f2', '\u7538', '\u5201',
|
|
||||||
'\u7239', '\u4e01', '\u4e1f', '\u4e1c', '\u543a', '\u53be',
|
|
||||||
'\u8011', '\u8968', '\u5428', '\u591a', '\u59b8', '\u8bf6',
|
|
||||||
'\u5940', '\u97a5', '\u513f', '\u53d1', '\u5e06', '\u531a',
|
|
||||||
'\u98de', '\u5206', '\u4e30', '\u8985', '\u4ecf', '\u7d11',
|
|
||||||
'\u4f15', '\u65ee', '\u4f85', '\u7518', '\u5188', '\u768b',
|
|
||||||
'\u6208', '\u7ed9', '\u6839', '\u522f', '\u5de5', '\u52fe',
|
|
||||||
'\u4f30', '\u74dc', '\u4e56', '\u5173', '\u5149', '\u5f52',
|
|
||||||
'\u4e28', '\u5459', '\u54c8', '\u548d', '\u4f44', '\u592f',
|
|
||||||
'\u8320', '\u8bc3', '\u9ed2', '\u62eb', '\u4ea8', '\u5677',
|
|
||||||
'\u53ff', '\u9f41', '\u4e6f', '\u82b1', '\u6000', '\u72bf',
|
|
||||||
'\u5ddf', '\u7070', '\u660f', '\u5419', '\u4e0c', '\u52a0',
|
|
||||||
'\u620b', '\u6c5f', '\u827d', '\u9636', '\u5dfe', '\u5755',
|
|
||||||
'\u5182', '\u4e29', '\u51e5', '\u59e2', '\u5658', '\u519b',
|
|
||||||
'\u5494', '\u5f00', '\u520a', '\u5ffc', '\u5c3b', '\u533c',
|
|
||||||
'\u808e', '\u52a5', '\u7a7a', '\u62a0', '\u625d', '\u5938',
|
|
||||||
'\u84af', '\u5bbd', '\u5321', '\u4e8f', '\u5764', '\u6269',
|
|
||||||
'\u5783', '\u6765', '\u5170', '\u5577', '\u635e', '\u808b',
|
|
||||||
'\u52d2', '\u5d1a', '\u5215', '\u4fe9', '\u5941', '\u826f',
|
|
||||||
'\u64a9', '\u5217', '\u62ce', '\u5222', '\u6e9c', '\u56d6',
|
|
||||||
'\u9f99', '\u779c', '\u565c', '\u5a08', '\u7567', '\u62a1',
|
|
||||||
'\u7f57', '\u5463', '\u5988', '\u57cb', '\u5ada', '\u7264',
|
|
||||||
'\u732b', '\u4e48', '\u5445', '\u95e8', '\u753f', '\u54aa',
|
|
||||||
'\u5b80', '\u55b5', '\u4e5c', '\u6c11', '\u540d', '\u8c2c',
|
|
||||||
'\u6478', '\u54de', '\u6bea', '\u55ef', '\u62cf', '\u8149',
|
|
||||||
'\u56e1', '\u56d4', '\u5b6c', '\u7592', '\u5a1e', '\u6041',
|
|
||||||
'\u80fd', '\u59ae', '\u62c8', '\u5b22', '\u9e1f', '\u634f',
|
|
||||||
'\u56dc', '\u5b81', '\u599e', '\u519c', '\u7fba', '\u5974',
|
|
||||||
'\u597b', '\u759f', '\u9ec1', '\u90cd', '\u5594', '\u8bb4',
|
|
||||||
'\u5991', '\u62cd', '\u7705', '\u4e53', '\u629b', '\u5478',
|
|
||||||
'\u55b7', '\u5309', '\u4e15', '\u56e8', '\u527d', '\u6c15',
|
|
||||||
'\u59d8', '\u4e52', '\u948b', '\u5256', '\u4ec6', '\u4e03',
|
|
||||||
'\u6390', '\u5343', '\u545b', '\u6084', '\u767f', '\u4eb2',
|
|
||||||
'\u72c5', '\u828e', '\u4e18', '\u533a', '\u5cd1', '\u7f3a',
|
|
||||||
'\u590b', '\u5465', '\u7a63', '\u5a06', '\u60f9', '\u4eba',
|
|
||||||
'\u6254', '\u65e5', '\u8338', '\u53b9', '\u909a', '\u633c',
|
|
||||||
'\u5827', '\u5a51', '\u77a4', '\u637c', '\u4ee8', '\u6be2',
|
|
||||||
'\u4e09', '\u6852', '\u63bb', '\u95aa', '\u68ee', '\u50e7',
|
|
||||||
'\u6740', '\u7b5b', '\u5c71', '\u4f24', '\u5f30', '\u5962',
|
|
||||||
'\u7533', '\u8398', '\u6552', '\u5347', '\u5c38', '\u53ce',
|
|
||||||
'\u4e66', '\u5237', '\u8870', '\u95e9', '\u53cc', '\u8c01',
|
|
||||||
'\u542e', '\u8bf4', '\u53b6', '\u5fea', '\u635c', '\u82cf',
|
|
||||||
'\u72fb', '\u590a', '\u5b59', '\u5506', '\u4ed6', '\u56fc',
|
|
||||||
'\u574d', '\u6c64', '\u5932', '\u5fd1', '\u71a5', '\u5254',
|
|
||||||
'\u5929', '\u65eb', '\u5e16', '\u5385', '\u56f2', '\u5077',
|
|
||||||
'\u51f8', '\u6e4d', '\u63a8', '\u541e', '\u4e47', '\u7a75',
|
|
||||||
'\u6b6a', '\u5f2f', '\u5c23', '\u5371', '\u6637', '\u7fc1',
|
|
||||||
'\u631d', '\u4e4c', '\u5915', '\u8672', '\u4eda', '\u4e61',
|
|
||||||
'\u7071', '\u4e9b', '\u5fc3', '\u661f', '\u51f6', '\u4f11',
|
|
||||||
'\u5401', '\u5405', '\u524a', '\u5743', '\u4e2b', '\u6079',
|
|
||||||
'\u592e', '\u5e7a', '\u503b', '\u4e00', '\u56d9', '\u5e94',
|
|
||||||
'\u54df', '\u4f63', '\u4f18', '\u625c', '\u56e6', '\u66f0',
|
|
||||||
'\u6655', '\u7b60', '\u7b7c', '\u5e00', '\u707d', '\u5142',
|
|
||||||
'\u5328', '\u50ae', '\u5219', '\u8d3c', '\u600e', '\u5897',
|
|
||||||
'\u624e', '\u635a', '\u6cbe', '\u5f20', '\u957f', '\u9577',
|
|
||||||
'\u4f4b', '\u8707', '\u8d1e', '\u4e89', '\u4e4b', '\u5cd9',
|
|
||||||
'\u5ea2', '\u4e2d', '\u5dde', '\u6731', '\u6293', '\u62fd',
|
|
||||||
'\u4e13', '\u5986', '\u96b9', '\u5b92', '\u5353', '\u4e72',
|
|
||||||
'\u5b97', '\u90b9', '\u79df', '\u94bb', '\u539c', '\u5c0a',
|
|
||||||
'\u6628', '\u5159', '\u9fc3', '\u9fc4',};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pinyin array.
|
|
||||||
* <p>
|
|
||||||
* Each pinyin is corresponding to unihans of same
|
|
||||||
* offset in the unihans array.
|
|
||||||
*/
|
|
||||||
public static final byte[][] PINYINS = {
|
|
||||||
{65, 0, 0, 0, 0, 0}, {65, 73, 0, 0, 0, 0},
|
|
||||||
{65, 78, 0, 0, 0, 0}, {65, 78, 71, 0, 0, 0},
|
|
||||||
{65, 79, 0, 0, 0, 0}, {66, 65, 0, 0, 0, 0},
|
|
||||||
{66, 65, 73, 0, 0, 0}, {66, 65, 78, 0, 0, 0},
|
|
||||||
{66, 65, 78, 71, 0, 0}, {66, 65, 79, 0, 0, 0},
|
|
||||||
{66, 69, 73, 0, 0, 0}, {66, 69, 78, 0, 0, 0},
|
|
||||||
{66, 69, 78, 71, 0, 0}, {66, 73, 0, 0, 0, 0},
|
|
||||||
{66, 73, 65, 78, 0, 0}, {66, 73, 65, 79, 0, 0},
|
|
||||||
{66, 73, 69, 0, 0, 0}, {66, 73, 78, 0, 0, 0},
|
|
||||||
{66, 73, 78, 71, 0, 0}, {66, 79, 0, 0, 0, 0},
|
|
||||||
{66, 85, 0, 0, 0, 0}, {67, 65, 0, 0, 0, 0},
|
|
||||||
{67, 65, 73, 0, 0, 0}, {67, 65, 78, 0, 0, 0},
|
|
||||||
{67, 65, 78, 71, 0, 0}, {67, 65, 79, 0, 0, 0},
|
|
||||||
{67, 69, 0, 0, 0, 0}, {67, 69, 78, 0, 0, 0},
|
|
||||||
{67, 69, 78, 71, 0, 0}, {90, 69, 78, 71, 0, 0},
|
|
||||||
{67, 69, 78, 71, 0, 0}, {67, 72, 65, 0, 0, 0},
|
|
||||||
{67, 72, 65, 73, 0, 0}, {67, 72, 65, 78, 0, 0},
|
|
||||||
{67, 72, 65, 78, 71, 0}, {67, 72, 65, 79, 0, 0},
|
|
||||||
{67, 72, 69, 0, 0, 0}, {67, 72, 69, 78, 0, 0},
|
|
||||||
{83, 72, 69, 78, 0, 0}, {67, 72, 69, 78, 0, 0},
|
|
||||||
{67, 72, 69, 78, 71, 0}, {67, 72, 73, 0, 0, 0},
|
|
||||||
{67, 72, 79, 78, 71, 0}, {67, 72, 79, 85, 0, 0},
|
|
||||||
{67, 72, 85, 0, 0, 0}, {67, 72, 85, 65, 0, 0},
|
|
||||||
{67, 72, 85, 65, 73, 0}, {67, 72, 85, 65, 78, 0},
|
|
||||||
{67, 72, 85, 65, 78, 71}, {67, 72, 85, 73, 0, 0},
|
|
||||||
{67, 72, 85, 78, 0, 0}, {67, 72, 85, 79, 0, 0},
|
|
||||||
{67, 73, 0, 0, 0, 0}, {67, 79, 78, 71, 0, 0},
|
|
||||||
{67, 79, 85, 0, 0, 0}, {67, 85, 0, 0, 0, 0},
|
|
||||||
{67, 85, 65, 78, 0, 0}, {67, 85, 73, 0, 0, 0},
|
|
||||||
{67, 85, 78, 0, 0, 0}, {67, 85, 79, 0, 0, 0},
|
|
||||||
{68, 65, 0, 0, 0, 0}, {68, 65, 73, 0, 0, 0},
|
|
||||||
{68, 65, 78, 0, 0, 0}, {68, 65, 78, 71, 0, 0},
|
|
||||||
{68, 65, 79, 0, 0, 0}, {68, 69, 0, 0, 0, 0},
|
|
||||||
{68, 69, 78, 0, 0, 0}, {68, 69, 78, 71, 0, 0},
|
|
||||||
{68, 73, 0, 0, 0, 0}, {68, 73, 65, 0, 0, 0},
|
|
||||||
{68, 73, 65, 78, 0, 0}, {68, 73, 65, 79, 0, 0},
|
|
||||||
{68, 73, 69, 0, 0, 0}, {68, 73, 78, 71, 0, 0},
|
|
||||||
{68, 73, 85, 0, 0, 0}, {68, 79, 78, 71, 0, 0},
|
|
||||||
{68, 79, 85, 0, 0, 0}, {68, 85, 0, 0, 0, 0},
|
|
||||||
{68, 85, 65, 78, 0, 0}, {68, 85, 73, 0, 0, 0},
|
|
||||||
{68, 85, 78, 0, 0, 0}, {68, 85, 79, 0, 0, 0},
|
|
||||||
{69, 0, 0, 0, 0, 0}, {69, 73, 0, 0, 0, 0},
|
|
||||||
{69, 78, 0, 0, 0, 0}, {69, 78, 71, 0, 0, 0},
|
|
||||||
{69, 82, 0, 0, 0, 0}, {70, 65, 0, 0, 0, 0},
|
|
||||||
{70, 65, 78, 0, 0, 0}, {70, 65, 78, 71, 0, 0},
|
|
||||||
{70, 69, 73, 0, 0, 0}, {70, 69, 78, 0, 0, 0},
|
|
||||||
{70, 69, 78, 71, 0, 0}, {70, 73, 65, 79, 0, 0},
|
|
||||||
{70, 79, 0, 0, 0, 0}, {70, 79, 85, 0, 0, 0},
|
|
||||||
{70, 85, 0, 0, 0, 0}, {71, 65, 0, 0, 0, 0},
|
|
||||||
{71, 65, 73, 0, 0, 0}, {71, 65, 78, 0, 0, 0},
|
|
||||||
{71, 65, 78, 71, 0, 0}, {71, 65, 79, 0, 0, 0},
|
|
||||||
{71, 69, 0, 0, 0, 0}, {71, 69, 73, 0, 0, 0},
|
|
||||||
{71, 69, 78, 0, 0, 0}, {71, 69, 78, 71, 0, 0},
|
|
||||||
{71, 79, 78, 71, 0, 0}, {71, 79, 85, 0, 0, 0},
|
|
||||||
{71, 85, 0, 0, 0, 0}, {71, 85, 65, 0, 0, 0},
|
|
||||||
{71, 85, 65, 73, 0, 0}, {71, 85, 65, 78, 0, 0},
|
|
||||||
{71, 85, 65, 78, 71, 0}, {71, 85, 73, 0, 0, 0},
|
|
||||||
{71, 85, 78, 0, 0, 0}, {71, 85, 79, 0, 0, 0},
|
|
||||||
{72, 65, 0, 0, 0, 0}, {72, 65, 73, 0, 0, 0},
|
|
||||||
{72, 65, 78, 0, 0, 0}, {72, 65, 78, 71, 0, 0},
|
|
||||||
{72, 65, 79, 0, 0, 0}, {72, 69, 0, 0, 0, 0},
|
|
||||||
{72, 69, 73, 0, 0, 0}, {72, 69, 78, 0, 0, 0},
|
|
||||||
{72, 69, 78, 71, 0, 0}, {72, 77, 0, 0, 0, 0},
|
|
||||||
{72, 79, 78, 71, 0, 0}, {72, 79, 85, 0, 0, 0},
|
|
||||||
{72, 85, 0, 0, 0, 0}, {72, 85, 65, 0, 0, 0},
|
|
||||||
{72, 85, 65, 73, 0, 0}, {72, 85, 65, 78, 0, 0},
|
|
||||||
{72, 85, 65, 78, 71, 0}, {72, 85, 73, 0, 0, 0},
|
|
||||||
{72, 85, 78, 0, 0, 0}, {72, 85, 79, 0, 0, 0},
|
|
||||||
{74, 73, 0, 0, 0, 0}, {74, 73, 65, 0, 0, 0},
|
|
||||||
{74, 73, 65, 78, 0, 0}, {74, 73, 65, 78, 71, 0},
|
|
||||||
{74, 73, 65, 79, 0, 0}, {74, 73, 69, 0, 0, 0},
|
|
||||||
{74, 73, 78, 0, 0, 0}, {74, 73, 78, 71, 0, 0},
|
|
||||||
{74, 73, 79, 78, 71, 0}, {74, 73, 85, 0, 0, 0},
|
|
||||||
{74, 85, 0, 0, 0, 0}, {74, 85, 65, 78, 0, 0},
|
|
||||||
{74, 85, 69, 0, 0, 0}, {74, 85, 78, 0, 0, 0},
|
|
||||||
{75, 65, 0, 0, 0, 0}, {75, 65, 73, 0, 0, 0},
|
|
||||||
{75, 65, 78, 0, 0, 0}, {75, 65, 78, 71, 0, 0},
|
|
||||||
{75, 65, 79, 0, 0, 0}, {75, 69, 0, 0, 0, 0},
|
|
||||||
{75, 69, 78, 0, 0, 0}, {75, 69, 78, 71, 0, 0},
|
|
||||||
{75, 79, 78, 71, 0, 0}, {75, 79, 85, 0, 0, 0},
|
|
||||||
{75, 85, 0, 0, 0, 0}, {75, 85, 65, 0, 0, 0},
|
|
||||||
{75, 85, 65, 73, 0, 0}, {75, 85, 65, 78, 0, 0},
|
|
||||||
{75, 85, 65, 78, 71, 0}, {75, 85, 73, 0, 0, 0},
|
|
||||||
{75, 85, 78, 0, 0, 0}, {75, 85, 79, 0, 0, 0},
|
|
||||||
{76, 65, 0, 0, 0, 0}, {76, 65, 73, 0, 0, 0},
|
|
||||||
{76, 65, 78, 0, 0, 0}, {76, 65, 78, 71, 0, 0},
|
|
||||||
{76, 65, 79, 0, 0, 0}, {76, 69, 0, 0, 0, 0},
|
|
||||||
{76, 69, 73, 0, 0, 0}, {76, 69, 78, 71, 0, 0},
|
|
||||||
{76, 73, 0, 0, 0, 0}, {76, 73, 65, 0, 0, 0},
|
|
||||||
{76, 73, 65, 78, 0, 0}, {76, 73, 65, 78, 71, 0},
|
|
||||||
{76, 73, 65, 79, 0, 0}, {76, 73, 69, 0, 0, 0},
|
|
||||||
{76, 73, 78, 0, 0, 0}, {76, 73, 78, 71, 0, 0},
|
|
||||||
{76, 73, 85, 0, 0, 0}, {76, 79, 0, 0, 0, 0},
|
|
||||||
{76, 79, 78, 71, 0, 0}, {76, 79, 85, 0, 0, 0},
|
|
||||||
{76, 85, 0, 0, 0, 0}, {76, 85, 65, 78, 0, 0},
|
|
||||||
{76, 85, 69, 0, 0, 0}, {76, 85, 78, 0, 0, 0},
|
|
||||||
{76, 85, 79, 0, 0, 0}, {77, 0, 0, 0, 0, 0},
|
|
||||||
{77, 65, 0, 0, 0, 0}, {77, 65, 73, 0, 0, 0},
|
|
||||||
{77, 65, 78, 0, 0, 0}, {77, 65, 78, 71, 0, 0},
|
|
||||||
{77, 65, 79, 0, 0, 0}, {77, 69, 0, 0, 0, 0},
|
|
||||||
{77, 69, 73, 0, 0, 0}, {77, 69, 78, 0, 0, 0},
|
|
||||||
{77, 69, 78, 71, 0, 0}, {77, 73, 0, 0, 0, 0},
|
|
||||||
{77, 73, 65, 78, 0, 0}, {77, 73, 65, 79, 0, 0},
|
|
||||||
{77, 73, 69, 0, 0, 0}, {77, 73, 78, 0, 0, 0},
|
|
||||||
{77, 73, 78, 71, 0, 0}, {77, 73, 85, 0, 0, 0},
|
|
||||||
{77, 79, 0, 0, 0, 0}, {77, 79, 85, 0, 0, 0},
|
|
||||||
{77, 85, 0, 0, 0, 0}, {78, 0, 0, 0, 0, 0},
|
|
||||||
{78, 65, 0, 0, 0, 0}, {78, 65, 73, 0, 0, 0},
|
|
||||||
{78, 65, 78, 0, 0, 0}, {78, 65, 78, 71, 0, 0},
|
|
||||||
{78, 65, 79, 0, 0, 0}, {78, 69, 0, 0, 0, 0},
|
|
||||||
{78, 69, 73, 0, 0, 0}, {78, 69, 78, 0, 0, 0},
|
|
||||||
{78, 69, 78, 71, 0, 0}, {78, 73, 0, 0, 0, 0},
|
|
||||||
{78, 73, 65, 78, 0, 0}, {78, 73, 65, 78, 71, 0},
|
|
||||||
{78, 73, 65, 79, 0, 0}, {78, 73, 69, 0, 0, 0},
|
|
||||||
{78, 73, 78, 0, 0, 0}, {78, 73, 78, 71, 0, 0},
|
|
||||||
{78, 73, 85, 0, 0, 0}, {78, 79, 78, 71, 0, 0},
|
|
||||||
{78, 79, 85, 0, 0, 0}, {78, 85, 0, 0, 0, 0},
|
|
||||||
{78, 85, 65, 78, 0, 0}, {78, 85, 69, 0, 0, 0},
|
|
||||||
{78, 85, 78, 0, 0, 0}, {78, 85, 79, 0, 0, 0},
|
|
||||||
{79, 0, 0, 0, 0, 0}, {79, 85, 0, 0, 0, 0},
|
|
||||||
{80, 65, 0, 0, 0, 0}, {80, 65, 73, 0, 0, 0},
|
|
||||||
{80, 65, 78, 0, 0, 0}, {80, 65, 78, 71, 0, 0},
|
|
||||||
{80, 65, 79, 0, 0, 0}, {80, 69, 73, 0, 0, 0},
|
|
||||||
{80, 69, 78, 0, 0, 0}, {80, 69, 78, 71, 0, 0},
|
|
||||||
{80, 73, 0, 0, 0, 0}, {80, 73, 65, 78, 0, 0},
|
|
||||||
{80, 73, 65, 79, 0, 0}, {80, 73, 69, 0, 0, 0},
|
|
||||||
{80, 73, 78, 0, 0, 0}, {80, 73, 78, 71, 0, 0},
|
|
||||||
{80, 79, 0, 0, 0, 0}, {80, 79, 85, 0, 0, 0},
|
|
||||||
{80, 85, 0, 0, 0, 0}, {81, 73, 0, 0, 0, 0},
|
|
||||||
{81, 73, 65, 0, 0, 0}, {81, 73, 65, 78, 0, 0},
|
|
||||||
{81, 73, 65, 78, 71, 0}, {81, 73, 65, 79, 0, 0},
|
|
||||||
{81, 73, 69, 0, 0, 0}, {81, 73, 78, 0, 0, 0},
|
|
||||||
{81, 73, 78, 71, 0, 0}, {81, 73, 79, 78, 71, 0},
|
|
||||||
{81, 73, 85, 0, 0, 0}, {81, 85, 0, 0, 0, 0},
|
|
||||||
{81, 85, 65, 78, 0, 0}, {81, 85, 69, 0, 0, 0},
|
|
||||||
{81, 85, 78, 0, 0, 0}, {82, 65, 78, 0, 0, 0},
|
|
||||||
{82, 65, 78, 71, 0, 0}, {82, 65, 79, 0, 0, 0},
|
|
||||||
{82, 69, 0, 0, 0, 0}, {82, 69, 78, 0, 0, 0},
|
|
||||||
{82, 69, 78, 71, 0, 0}, {82, 73, 0, 0, 0, 0},
|
|
||||||
{82, 79, 78, 71, 0, 0}, {82, 79, 85, 0, 0, 0},
|
|
||||||
{82, 85, 0, 0, 0, 0}, {82, 85, 65, 0, 0, 0},
|
|
||||||
{82, 85, 65, 78, 0, 0}, {82, 85, 73, 0, 0, 0},
|
|
||||||
{82, 85, 78, 0, 0, 0}, {82, 85, 79, 0, 0, 0},
|
|
||||||
{83, 65, 0, 0, 0, 0}, {83, 65, 73, 0, 0, 0},
|
|
||||||
{83, 65, 78, 0, 0, 0}, {83, 65, 78, 71, 0, 0},
|
|
||||||
{83, 65, 79, 0, 0, 0}, {83, 69, 0, 0, 0, 0},
|
|
||||||
{83, 69, 78, 0, 0, 0}, {83, 69, 78, 71, 0, 0},
|
|
||||||
{83, 72, 65, 0, 0, 0}, {83, 72, 65, 73, 0, 0},
|
|
||||||
{83, 72, 65, 78, 0, 0}, {83, 72, 65, 78, 71, 0},
|
|
||||||
{83, 72, 65, 79, 0, 0}, {83, 72, 69, 0, 0, 0},
|
|
||||||
{83, 72, 69, 78, 0, 0}, {88, 73, 78, 0, 0, 0},
|
|
||||||
{83, 72, 69, 78, 0, 0}, {83, 72, 69, 78, 71, 0},
|
|
||||||
{83, 72, 73, 0, 0, 0}, {83, 72, 79, 85, 0, 0},
|
|
||||||
{83, 72, 85, 0, 0, 0}, {83, 72, 85, 65, 0, 0},
|
|
||||||
{83, 72, 85, 65, 73, 0}, {83, 72, 85, 65, 78, 0},
|
|
||||||
{83, 72, 85, 65, 78, 71}, {83, 72, 85, 73, 0, 0},
|
|
||||||
{83, 72, 85, 78, 0, 0}, {83, 72, 85, 79, 0, 0},
|
|
||||||
{83, 73, 0, 0, 0, 0}, {83, 79, 78, 71, 0, 0},
|
|
||||||
{83, 79, 85, 0, 0, 0}, {83, 85, 0, 0, 0, 0},
|
|
||||||
{83, 85, 65, 78, 0, 0}, {83, 85, 73, 0, 0, 0},
|
|
||||||
{83, 85, 78, 0, 0, 0}, {83, 85, 79, 0, 0, 0},
|
|
||||||
{84, 65, 0, 0, 0, 0}, {84, 65, 73, 0, 0, 0},
|
|
||||||
{84, 65, 78, 0, 0, 0}, {84, 65, 78, 71, 0, 0},
|
|
||||||
{84, 65, 79, 0, 0, 0}, {84, 69, 0, 0, 0, 0},
|
|
||||||
{84, 69, 78, 71, 0, 0}, {84, 73, 0, 0, 0, 0},
|
|
||||||
{84, 73, 65, 78, 0, 0}, {84, 73, 65, 79, 0, 0},
|
|
||||||
{84, 73, 69, 0, 0, 0}, {84, 73, 78, 71, 0, 0},
|
|
||||||
{84, 79, 78, 71, 0, 0}, {84, 79, 85, 0, 0, 0},
|
|
||||||
{84, 85, 0, 0, 0, 0}, {84, 85, 65, 78, 0, 0},
|
|
||||||
{84, 85, 73, 0, 0, 0}, {84, 85, 78, 0, 0, 0},
|
|
||||||
{84, 85, 79, 0, 0, 0}, {87, 65, 0, 0, 0, 0},
|
|
||||||
{87, 65, 73, 0, 0, 0}, {87, 65, 78, 0, 0, 0},
|
|
||||||
{87, 65, 78, 71, 0, 0}, {87, 69, 73, 0, 0, 0},
|
|
||||||
{87, 69, 78, 0, 0, 0}, {87, 69, 78, 71, 0, 0},
|
|
||||||
{87, 79, 0, 0, 0, 0}, {87, 85, 0, 0, 0, 0},
|
|
||||||
{88, 73, 0, 0, 0, 0}, {88, 73, 65, 0, 0, 0},
|
|
||||||
{88, 73, 65, 78, 0, 0}, {88, 73, 65, 78, 71, 0},
|
|
||||||
{88, 73, 65, 79, 0, 0}, {88, 73, 69, 0, 0, 0},
|
|
||||||
{88, 73, 78, 0, 0, 0}, {88, 73, 78, 71, 0, 0},
|
|
||||||
{88, 73, 79, 78, 71, 0}, {88, 73, 85, 0, 0, 0},
|
|
||||||
{88, 85, 0, 0, 0, 0}, {88, 85, 65, 78, 0, 0},
|
|
||||||
{88, 85, 69, 0, 0, 0}, {88, 85, 78, 0, 0, 0},
|
|
||||||
{89, 65, 0, 0, 0, 0}, {89, 65, 78, 0, 0, 0},
|
|
||||||
{89, 65, 78, 71, 0, 0}, {89, 65, 79, 0, 0, 0},
|
|
||||||
{89, 69, 0, 0, 0, 0}, {89, 73, 0, 0, 0, 0},
|
|
||||||
{89, 73, 78, 0, 0, 0}, {89, 73, 78, 71, 0, 0},
|
|
||||||
{89, 79, 0, 0, 0, 0}, {89, 79, 78, 71, 0, 0},
|
|
||||||
{89, 79, 85, 0, 0, 0}, {89, 85, 0, 0, 0, 0},
|
|
||||||
{89, 85, 65, 78, 0, 0}, {89, 85, 69, 0, 0, 0},
|
|
||||||
{89, 85, 78, 0, 0, 0}, {74, 85, 78, 0, 0, 0},
|
|
||||||
{89, 85, 78, 0, 0, 0}, {90, 65, 0, 0, 0, 0},
|
|
||||||
{90, 65, 73, 0, 0, 0}, {90, 65, 78, 0, 0, 0},
|
|
||||||
{90, 65, 78, 71, 0, 0}, {90, 65, 79, 0, 0, 0},
|
|
||||||
{90, 69, 0, 0, 0, 0}, {90, 69, 73, 0, 0, 0},
|
|
||||||
{90, 69, 78, 0, 0, 0}, {90, 69, 78, 71, 0, 0},
|
|
||||||
{90, 72, 65, 0, 0, 0}, {90, 72, 65, 73, 0, 0},
|
|
||||||
{90, 72, 65, 78, 0, 0}, {90, 72, 65, 78, 71, 0},
|
|
||||||
{67, 72, 65, 78, 71, 0}, {90, 72, 65, 78, 71, 0},
|
|
||||||
{90, 72, 65, 79, 0, 0}, {90, 72, 69, 0, 0, 0},
|
|
||||||
{90, 72, 69, 78, 0, 0}, {90, 72, 69, 78, 71, 0},
|
|
||||||
{90, 72, 73, 0, 0, 0}, {83, 72, 73, 0, 0, 0},
|
|
||||||
{90, 72, 73, 0, 0, 0}, {90, 72, 79, 78, 71, 0},
|
|
||||||
{90, 72, 79, 85, 0, 0}, {90, 72, 85, 0, 0, 0},
|
|
||||||
{90, 72, 85, 65, 0, 0}, {90, 72, 85, 65, 73, 0},
|
|
||||||
{90, 72, 85, 65, 78, 0}, {90, 72, 85, 65, 78, 71},
|
|
||||||
{90, 72, 85, 73, 0, 0}, {90, 72, 85, 78, 0, 0},
|
|
||||||
{90, 72, 85, 79, 0, 0}, {90, 73, 0, 0, 0, 0},
|
|
||||||
{90, 79, 78, 71, 0, 0}, {90, 79, 85, 0, 0, 0},
|
|
||||||
{90, 85, 0, 0, 0, 0}, {90, 85, 65, 78, 0, 0},
|
|
||||||
{90, 85, 73, 0, 0, 0}, {90, 85, 78, 0, 0, 0},
|
|
||||||
{90, 85, 79, 0, 0, 0}, {0, 0, 0, 0, 0, 0},
|
|
||||||
{83, 72, 65, 78, 0, 0}, {0, 0, 0, 0, 0, 0},};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* First and last Chinese character with known Pinyin according to zh collation
|
|
||||||
*/
|
|
||||||
private static final String FIRST_PINYIN_UNIHAN = "\u963F";
|
|
||||||
private static final String LAST_PINYIN_UNIHAN = "\u9FFF";
|
|
||||||
|
|
||||||
private static final Collator COLLATOR = Collator.getInstance(Locale.CHINA);
|
|
||||||
|
|
||||||
private static HanziToPinyin sInstance;
|
|
||||||
private final boolean mHasChinaCollator;
|
|
||||||
|
|
||||||
public static class Token {
|
|
||||||
/**
|
|
||||||
* Separator between target string for each source char
|
|
||||||
*/
|
|
||||||
public static final String SEPARATOR = " ";
|
|
||||||
|
|
||||||
public static final int LATIN = 1;
|
|
||||||
public static final int PINYIN = 2;
|
|
||||||
public static final int UNKNOWN = 3;
|
|
||||||
|
|
||||||
public Token() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public Token(int type, String source, String target) {
|
|
||||||
this.type = type;
|
|
||||||
this.source = source;
|
|
||||||
this.target = target;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type of this token, ASCII, PINYIN or UNKNOWN.
|
|
||||||
*/
|
|
||||||
public int type;
|
|
||||||
/**
|
|
||||||
* Original string before translation.
|
|
||||||
*/
|
|
||||||
public String source;
|
|
||||||
/**
|
|
||||||
* Translated string of source. For Han, target is corresponding Pinyin. Otherwise target is
|
|
||||||
* original string in source.
|
|
||||||
*/
|
|
||||||
public String target;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected HanziToPinyin(boolean hasChinaCollator) {
|
|
||||||
mHasChinaCollator = hasChinaCollator;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static HanziToPinyin getInstance() {
|
|
||||||
synchronized (HanziToPinyin.class) {
|
|
||||||
if (sInstance != null) {
|
|
||||||
return sInstance;
|
|
||||||
}
|
|
||||||
// Check if zh_CN collation data is available
|
|
||||||
final Locale[] locale = Collator.getAvailableLocales();
|
|
||||||
for (Locale value : locale) {
|
|
||||||
if (value.equals(Locale.CHINA) || value.getLanguage().contains("zh")) {
|
|
||||||
// Do self validation just once.
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.d(TAG, "Self validation. Result: " + doSelfValidation());
|
|
||||||
}
|
|
||||||
sInstance = new HanziToPinyin(true);
|
|
||||||
return sInstance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sInstance == null){//这个判断是用于处理国产ROM的兼容性问题
|
|
||||||
if (Locale.CHINA.equals(Locale.getDefault())){
|
|
||||||
sInstance = new HanziToPinyin(true);
|
|
||||||
return sInstance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.w(TAG, "There is no Chinese collator, HanziToPinyin is disabled");
|
|
||||||
sInstance = new HanziToPinyin(false);
|
|
||||||
return sInstance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate if our internal table has some wrong value.
|
|
||||||
*
|
|
||||||
* @return true when the table looks correct.
|
|
||||||
*/
|
|
||||||
private static boolean doSelfValidation() {
|
|
||||||
char lastChar = UNIHANS[0];
|
|
||||||
String lastString = Character.toString(lastChar);
|
|
||||||
for (char c : UNIHANS) {
|
|
||||||
if (lastChar == c) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final String curString = Character.toString(c);
|
|
||||||
int cmp = COLLATOR.compare(lastString, curString);
|
|
||||||
if (cmp >= 0) {
|
|
||||||
Log.e(TAG, "Internal error in Unihan table. " + "The last string \"" + lastString
|
|
||||||
+ "\" is greater than current string \"" + curString + "\".");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
lastString = curString;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Token getToken(char character) {
|
|
||||||
Token token = new Token();
|
|
||||||
final String letter = Character.toString(character);
|
|
||||||
token.source = letter;
|
|
||||||
int offset = -1;
|
|
||||||
int cmp;
|
|
||||||
if (character < 256) {
|
|
||||||
token.type = Token.LATIN;
|
|
||||||
token.target = letter;
|
|
||||||
return token;
|
|
||||||
} else {
|
|
||||||
cmp = COLLATOR.compare(letter, FIRST_PINYIN_UNIHAN);
|
|
||||||
if (cmp < 0) {
|
|
||||||
token.type = Token.UNKNOWN;
|
|
||||||
token.target = letter;
|
|
||||||
return token;
|
|
||||||
} else if (cmp == 0) {
|
|
||||||
token.type = Token.PINYIN;
|
|
||||||
offset = 0;
|
|
||||||
} else {
|
|
||||||
cmp = COLLATOR.compare(letter, LAST_PINYIN_UNIHAN);
|
|
||||||
if (cmp > 0) {
|
|
||||||
token.type = Token.UNKNOWN;
|
|
||||||
token.target = letter;
|
|
||||||
return token;
|
|
||||||
} else if (cmp == 0) {
|
|
||||||
token.type = Token.PINYIN;
|
|
||||||
offset = UNIHANS.length - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
token.type = Token.PINYIN;
|
|
||||||
if (offset < 0) {
|
|
||||||
int begin = 0;
|
|
||||||
int end = UNIHANS.length - 1;
|
|
||||||
while (begin <= end) {
|
|
||||||
offset = (begin + end) / 2;
|
|
||||||
final String unihan = Character.toString(UNIHANS[offset]);
|
|
||||||
cmp = COLLATOR.compare(letter, unihan);
|
|
||||||
if (cmp == 0) {
|
|
||||||
break;
|
|
||||||
} else if (cmp > 0) {
|
|
||||||
begin = offset + 1;
|
|
||||||
} else {
|
|
||||||
end = offset - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cmp < 0) {
|
|
||||||
offset--;
|
|
||||||
}
|
|
||||||
StringBuilder pinyin = new StringBuilder();
|
|
||||||
for (int j = 0; j < PINYINS[offset].length && PINYINS[offset][j] != 0; j++) {
|
|
||||||
pinyin.append((char) PINYINS[offset][j]);
|
|
||||||
}
|
|
||||||
token.target = pinyin.toString();
|
|
||||||
if (TextUtils.isEmpty(token.target)) {
|
|
||||||
token.type = Token.UNKNOWN;
|
|
||||||
token.target = token.source;
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert the input to a array of tokens. The sequence of ASCII or Unknown characters without
|
|
||||||
* space will be put into a Token, One Hanzi character which has pinyin will be treated as a
|
|
||||||
* Token. If these is no China collator, the empty token array is returned.
|
|
||||||
*/
|
|
||||||
public ArrayList<Token> get(final String input) {
|
|
||||||
ArrayList<Token> tokens = new ArrayList<>();
|
|
||||||
if (!mHasChinaCollator || TextUtils.isEmpty(input)) {
|
|
||||||
// return empty tokens.
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
final int inputLength = input.length();
|
|
||||||
final StringBuilder sb = new StringBuilder();
|
|
||||||
int tokenType = Token.LATIN;
|
|
||||||
// Go through the input, create a new token when
|
|
||||||
// a. Token type changed
|
|
||||||
// b. Get the Pinyin of current charater.
|
|
||||||
// c. current character is space.
|
|
||||||
for (int i = 0; i < inputLength; i++) {
|
|
||||||
final char character = input.charAt(i);
|
|
||||||
if (character == ' ') {
|
|
||||||
if (sb.length() > 0) {
|
|
||||||
addToken(sb, tokens, tokenType);
|
|
||||||
}
|
|
||||||
} else if (character < 256) {
|
|
||||||
if (tokenType != Token.LATIN && sb.length() > 0) {
|
|
||||||
addToken(sb, tokens, tokenType);
|
|
||||||
}
|
|
||||||
tokenType = Token.LATIN;
|
|
||||||
sb.append(character);
|
|
||||||
} else {
|
|
||||||
Token t = getToken(character);
|
|
||||||
if (t.type == Token.PINYIN) {
|
|
||||||
if (sb.length() > 0) {
|
|
||||||
addToken(sb, tokens, tokenType);
|
|
||||||
}
|
|
||||||
tokens.add(t);
|
|
||||||
tokenType = Token.PINYIN;
|
|
||||||
} else {
|
|
||||||
if (tokenType != t.type && sb.length() > 0) {
|
|
||||||
addToken(sb, tokens, tokenType);
|
|
||||||
}
|
|
||||||
tokenType = t.type;
|
|
||||||
sb.append(character);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sb.length() > 0) {
|
|
||||||
addToken(sb, tokens, tokenType);
|
|
||||||
}
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void addToken(
|
|
||||||
final StringBuilder sb, final ArrayList<Token> tokens, final int tokenType) {
|
|
||||||
String str = sb.toString();
|
|
||||||
tokens.add(new Token(tokenType, str, str));
|
|
||||||
sb.setLength(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String toPinyinString(String string) {
|
|
||||||
if (string == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
ArrayList<Token> tokens = get(string);
|
|
||||||
for (Token token : tokens) {
|
|
||||||
sb.append(token.target);
|
|
||||||
}
|
|
||||||
return sb.toString().toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.util
|
|
||||||
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import java.util.regex.Pattern
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LinkifyText(
|
|
||||||
text: String,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val uriHandler = LocalUriHandler.current
|
|
||||||
val layoutResult = remember {
|
|
||||||
mutableStateOf<TextLayoutResult?>(null)
|
|
||||||
}
|
|
||||||
val linksList = extractUrls(text)
|
|
||||||
val annotatedString = buildAnnotatedString {
|
|
||||||
append(text)
|
|
||||||
linksList.forEach {
|
|
||||||
addStyle(
|
|
||||||
style = SpanStyle(
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
textDecoration = TextDecoration.Underline
|
|
||||||
),
|
|
||||||
start = it.start,
|
|
||||||
end = it.end
|
|
||||||
)
|
|
||||||
addStringAnnotation(
|
|
||||||
tag = "URL",
|
|
||||||
annotation = it.url,
|
|
||||||
start = it.start,
|
|
||||||
end = it.end
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = annotatedString,
|
|
||||||
modifier = modifier.pointerInput(Unit) {
|
|
||||||
detectTapGestures { offsetPosition ->
|
|
||||||
layoutResult.value?.let {
|
|
||||||
val position = it.getOffsetForPosition(offsetPosition)
|
|
||||||
annotatedString.getStringAnnotations(position, position).firstOrNull()
|
|
||||||
?.let { result ->
|
|
||||||
if (result.tag == "URL") {
|
|
||||||
uriHandler.openUri(result.item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onTextLayout = { layoutResult.value = it }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val urlPattern: Pattern = Pattern.compile(
|
|
||||||
"(?:^|[\\W])((ht|f)tp(s?):\\/\\/|www\\.)"
|
|
||||||
+ "(([\\w\\-]+\\.){1,}?([\\w\\-.~]+\\/?)*"
|
|
||||||
+ "[\\p{Alnum}.,%_=?&#\\-+()\\[\\]\\*$~@!:/{};']*)",
|
|
||||||
Pattern.CASE_INSENSITIVE or Pattern.MULTILINE or Pattern.DOTALL
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class LinkInfo(
|
|
||||||
val url: String,
|
|
||||||
val start: Int,
|
|
||||||
val end: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun extractUrls(text: String): List<LinkInfo> = buildList {
|
|
||||||
val matcher = urlPattern.matcher(text)
|
|
||||||
while (matcher.find()) {
|
|
||||||
val matchStart = matcher.start(1)
|
|
||||||
val matchEnd = matcher.end()
|
|
||||||
val url = text.substring(matchStart, matchEnd).replaceFirst("http://", "https://")
|
|
||||||
add(LinkInfo(url, matchStart, matchEnd))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,550 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.util
|
|
||||||
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.Cursor
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Environment
|
|
||||||
import android.os.Parcelable
|
|
||||||
import android.os.SystemClock
|
|
||||||
import android.provider.OpenableColumns
|
|
||||||
import android.system.Os
|
|
||||||
import android.util.Log
|
|
||||||
import com.topjohnwu.superuser.CallbackList
|
|
||||||
import com.topjohnwu.superuser.Shell
|
|
||||||
import com.topjohnwu.superuser.ShellUtils
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
import com.sukisu.ultra.BuildConfig
|
|
||||||
import com.sukisu.ultra.Natives
|
|
||||||
import com.sukisu.ultra.ksuApp
|
|
||||||
import org.json.JSONArray
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author weishu
|
|
||||||
* @date 2023/1/1.
|
|
||||||
*/
|
|
||||||
private const val TAG = "KsuCli"
|
|
||||||
|
|
||||||
private fun getKsuDaemonPath(): String {
|
|
||||||
return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libzakozako.so"
|
|
||||||
}
|
|
||||||
|
|
||||||
object KsuCli {
|
|
||||||
val SHELL: Shell = createRootShell()
|
|
||||||
val GLOBAL_MNT_SHELL: Shell = createRootShell(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getRootShell(globalMnt: Boolean = false): Shell {
|
|
||||||
return if (globalMnt) KsuCli.GLOBAL_MNT_SHELL else {
|
|
||||||
KsuCli.SHELL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun <T> withNewRootShell(
|
|
||||||
globalMnt: Boolean = false,
|
|
||||||
block: Shell.() -> T
|
|
||||||
): T {
|
|
||||||
return createRootShell(globalMnt).use(block)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Uri.getFileName(context: Context): String? {
|
|
||||||
var fileName: String? = null
|
|
||||||
val contentResolver: ContentResolver = context.contentResolver
|
|
||||||
val cursor: Cursor? = contentResolver.query(this, null, null, null, null)
|
|
||||||
cursor?.use {
|
|
||||||
if (it.moveToFirst()) {
|
|
||||||
fileName = it.getString(it.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fileName
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createRootShell(globalMnt: Boolean = false): Shell {
|
|
||||||
Shell.enableVerboseLogging = BuildConfig.DEBUG
|
|
||||||
val builder = Shell.Builder.create()
|
|
||||||
return try {
|
|
||||||
if (globalMnt) {
|
|
||||||
builder.build(getKsuDaemonPath(), "debug", "su", "-g")
|
|
||||||
} else {
|
|
||||||
builder.build(getKsuDaemonPath(), "debug", "su")
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.w(TAG, "ksu failed: ", e)
|
|
||||||
try {
|
|
||||||
if (globalMnt) {
|
|
||||||
builder.build("su")
|
|
||||||
} else {
|
|
||||||
builder.build("su", "-mm")
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.e(TAG, "su failed: ", e)
|
|
||||||
builder.build("sh")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun execKsud(args: String, newShell: Boolean = false): Boolean {
|
|
||||||
return if (newShell) {
|
|
||||||
withNewRootShell {
|
|
||||||
ShellUtils.fastCmdResult(this, "${getKsuDaemonPath()} $args")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ShellUtils.fastCmdResult(getRootShell(), "${getKsuDaemonPath()} $args")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun install() {
|
|
||||||
val start = SystemClock.elapsedRealtime()
|
|
||||||
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libzakoboot.so").absolutePath
|
|
||||||
val result = execKsud("install --magiskboot $magiskboot", true)
|
|
||||||
Log.w(TAG, "install result: $result, cost: ${SystemClock.elapsedRealtime() - start}ms")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun listModules(): String {
|
|
||||||
val shell = getRootShell()
|
|
||||||
|
|
||||||
val out =
|
|
||||||
shell.newJob().add("${getKsuDaemonPath()} module list").to(ArrayList(), null).exec().out
|
|
||||||
return out.joinToString("\n").ifBlank { "[]" }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getModuleCount(): Int {
|
|
||||||
val result = listModules()
|
|
||||||
runCatching {
|
|
||||||
val array = JSONArray(result)
|
|
||||||
return array.length()
|
|
||||||
}.getOrElse { return 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSuperuserCount(): Int {
|
|
||||||
return Natives.allowList.size
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleModule(id: String, enable: Boolean): Boolean {
|
|
||||||
val cmd = if (enable) {
|
|
||||||
"module enable $id"
|
|
||||||
} else {
|
|
||||||
"module disable $id"
|
|
||||||
}
|
|
||||||
val result = execKsud(cmd, true)
|
|
||||||
Log.i(TAG, "$cmd result: $result")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun uninstallModule(id: String): Boolean {
|
|
||||||
val cmd = "module uninstall $id"
|
|
||||||
val result = execKsud(cmd, true)
|
|
||||||
Log.i(TAG, "uninstall module $id result: $result")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun restoreModule(id: String): Boolean {
|
|
||||||
val cmd = "module restore $id"
|
|
||||||
val result = execKsud(cmd, true)
|
|
||||||
Log.i(TAG, "restore module $id result: $result")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun flashWithIO(
|
|
||||||
cmd: String,
|
|
||||||
onStdout: (String) -> Unit,
|
|
||||||
onStderr: (String) -> Unit
|
|
||||||
): Shell.Result {
|
|
||||||
|
|
||||||
val stdoutCallback: CallbackList<String?> = object : CallbackList<String?>() {
|
|
||||||
override fun onAddElement(s: String?) {
|
|
||||||
onStdout(s ?: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val stderrCallback: CallbackList<String?> = object : CallbackList<String?>() {
|
|
||||||
override fun onAddElement(s: String?) {
|
|
||||||
onStderr(s ?: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return withNewRootShell {
|
|
||||||
newJob().add(cmd).to(stdoutCallback, stderrCallback).exec()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun flashModule(
|
|
||||||
uri: Uri,
|
|
||||||
onFinish: (Boolean, Int) -> Unit,
|
|
||||||
onStdout: (String) -> Unit,
|
|
||||||
onStderr: (String) -> Unit
|
|
||||||
): Boolean {
|
|
||||||
val resolver = ksuApp.contentResolver
|
|
||||||
with(resolver.openInputStream(uri)) {
|
|
||||||
val file = File(ksuApp.cacheDir, "module.zip")
|
|
||||||
file.outputStream().use { output ->
|
|
||||||
this?.copyTo(output)
|
|
||||||
}
|
|
||||||
val cmd = "module install ${file.absolutePath}"
|
|
||||||
val result = flashWithIO("${getKsuDaemonPath()} $cmd", onStdout, onStderr)
|
|
||||||
Log.i("KernelSU", "install module $uri result: $result")
|
|
||||||
|
|
||||||
file.delete()
|
|
||||||
|
|
||||||
onFinish(result.isSuccess, result.code)
|
|
||||||
return result.isSuccess
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun runModuleAction(
|
|
||||||
moduleId: String, onStdout: (String) -> Unit, onStderr: (String) -> Unit
|
|
||||||
): Boolean {
|
|
||||||
val shell = createRootShell(true)
|
|
||||||
|
|
||||||
val stdoutCallback: CallbackList<String?> = object : CallbackList<String?>() {
|
|
||||||
override fun onAddElement(s: String?) {
|
|
||||||
onStdout(s ?: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val stderrCallback: CallbackList<String?> = object : CallbackList<String?>() {
|
|
||||||
override fun onAddElement(s: String?) {
|
|
||||||
onStderr(s ?: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = shell.newJob().add("${getKsuDaemonPath()} module action $moduleId")
|
|
||||||
.to(stdoutCallback, stderrCallback).exec()
|
|
||||||
Log.i("KernelSU", "Module runAction result: $result")
|
|
||||||
|
|
||||||
return result.isSuccess
|
|
||||||
}
|
|
||||||
|
|
||||||
fun restoreBoot(
|
|
||||||
onFinish: (Boolean, Int) -> Unit, onStdout: (String) -> Unit, onStderr: (String) -> Unit
|
|
||||||
): Boolean {
|
|
||||||
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libzakoboot.so")
|
|
||||||
val result = flashWithIO("${getKsuDaemonPath()} boot-restore -f --magiskboot $magiskboot", onStdout, onStderr)
|
|
||||||
onFinish(result.isSuccess, result.code)
|
|
||||||
return result.isSuccess
|
|
||||||
}
|
|
||||||
|
|
||||||
fun uninstallPermanently(
|
|
||||||
onFinish: (Boolean, Int) -> Unit, onStdout: (String) -> Unit, onStderr: (String) -> Unit
|
|
||||||
): Boolean {
|
|
||||||
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libzakoboot.so")
|
|
||||||
val result = flashWithIO("${getKsuDaemonPath()} uninstall --magiskboot $magiskboot", onStdout, onStderr)
|
|
||||||
onFinish(result.isSuccess, result.code)
|
|
||||||
return result.isSuccess
|
|
||||||
}
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
sealed class LkmSelection : Parcelable {
|
|
||||||
data class LkmUri(val uri: Uri) : LkmSelection()
|
|
||||||
data class KmiString(val value: String) : LkmSelection()
|
|
||||||
data object KmiNone : LkmSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun installBoot(
|
|
||||||
bootUri: Uri?,
|
|
||||||
lkm: LkmSelection,
|
|
||||||
ota: Boolean,
|
|
||||||
onFinish: (Boolean, Int) -> Unit,
|
|
||||||
onStdout: (String) -> Unit,
|
|
||||||
onStderr: (String) -> Unit,
|
|
||||||
): Boolean {
|
|
||||||
val resolver = ksuApp.contentResolver
|
|
||||||
|
|
||||||
val bootFile = bootUri?.let { uri ->
|
|
||||||
with(resolver.openInputStream(uri)) {
|
|
||||||
val bootFile = File(ksuApp.cacheDir, "boot.img")
|
|
||||||
bootFile.outputStream().use { output ->
|
|
||||||
this?.copyTo(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
bootFile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libzakoboot.so")
|
|
||||||
var cmd = "boot-patch --magiskboot ${magiskboot.absolutePath}"
|
|
||||||
|
|
||||||
cmd += if (bootFile == null) {
|
|
||||||
// no boot.img, use -f to force install
|
|
||||||
" -f"
|
|
||||||
} else {
|
|
||||||
" -b ${bootFile.absolutePath}"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ota) {
|
|
||||||
cmd += " -u"
|
|
||||||
}
|
|
||||||
|
|
||||||
var lkmFile: File? = null
|
|
||||||
when (lkm) {
|
|
||||||
is LkmSelection.LkmUri -> {
|
|
||||||
lkmFile = with(resolver.openInputStream(lkm.uri)) {
|
|
||||||
val file = File(ksuApp.cacheDir, "kernelsu-tmp-lkm.ko")
|
|
||||||
file.outputStream().use { output ->
|
|
||||||
this?.copyTo(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
file
|
|
||||||
}
|
|
||||||
cmd += " -m ${lkmFile.absolutePath}"
|
|
||||||
}
|
|
||||||
|
|
||||||
is LkmSelection.KmiString -> {
|
|
||||||
cmd += " --kmi ${lkm.value}"
|
|
||||||
}
|
|
||||||
|
|
||||||
LkmSelection.KmiNone -> {
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// output dir
|
|
||||||
val downloadsDir =
|
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
|
||||||
cmd += " -o $downloadsDir"
|
|
||||||
|
|
||||||
val result = flashWithIO("${getKsuDaemonPath()} $cmd", onStdout, onStderr)
|
|
||||||
Log.i("KernelSU", "install boot result: ${result.isSuccess}")
|
|
||||||
|
|
||||||
bootFile?.delete()
|
|
||||||
lkmFile?.delete()
|
|
||||||
|
|
||||||
// if boot uri is empty, it is direct install, when success, we should show reboot button
|
|
||||||
onFinish(bootUri == null && result.isSuccess, result.code)
|
|
||||||
return result.isSuccess
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reboot(reason: String = "") {
|
|
||||||
val shell = getRootShell()
|
|
||||||
if (reason == "recovery") {
|
|
||||||
// KEYCODE_POWER = 26, hide incorrect "Factory data reset" message
|
|
||||||
ShellUtils.fastCmd(shell, "/system/bin/input keyevent 26")
|
|
||||||
}
|
|
||||||
ShellUtils.fastCmd(shell, "/system/bin/svc power reboot $reason || /system/bin/reboot $reason")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun rootAvailable(): Boolean {
|
|
||||||
val shell = getRootShell()
|
|
||||||
return shell.isRoot
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isAbDevice(): Boolean {
|
|
||||||
val shell = getRootShell()
|
|
||||||
return ShellUtils.fastCmd(shell, "getprop ro.build.ab_update").trim().toBoolean()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isInitBoot(): Boolean {
|
|
||||||
return !Os.uname().release.contains("android12-")
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getCurrentKmi(): String = withContext(Dispatchers.IO) {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val cmd = "boot-info current-kmi"
|
|
||||||
ShellUtils.fastCmd(shell, "${getKsuDaemonPath()} $cmd")
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getSupportedKmis(): List<String> = withContext(Dispatchers.IO) {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val cmd = "boot-info supported-kmi"
|
|
||||||
val out = shell.newJob().add("${getKsuDaemonPath()} $cmd").to(ArrayList(), null).exec().out
|
|
||||||
out.filter { it.isNotBlank() }.map { it.trim() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hasMagisk(): Boolean {
|
|
||||||
val shell = getRootShell(true)
|
|
||||||
val result = shell.newJob().add("which magisk").exec()
|
|
||||||
Log.i(TAG, "has magisk: ${result.isSuccess}")
|
|
||||||
return result.isSuccess
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isSepolicyValid(rules: String?): Boolean {
|
|
||||||
if (rules == null) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
val shell = getRootShell()
|
|
||||||
val result =
|
|
||||||
shell.newJob().add("${getKsuDaemonPath()} sepolicy check '$rules'").to(ArrayList(), null)
|
|
||||||
.exec()
|
|
||||||
return result.isSuccess
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSepolicy(pkg: String): String {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val result =
|
|
||||||
shell.newJob().add("${getKsuDaemonPath()} profile get-sepolicy $pkg").to(ArrayList(), null)
|
|
||||||
.exec()
|
|
||||||
Log.i(TAG, "code: ${result.code}, out: ${result.out}, err: ${result.err}")
|
|
||||||
return result.out.joinToString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSepolicy(pkg: String, rules: String): Boolean {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val result = shell.newJob().add("${getKsuDaemonPath()} profile set-sepolicy $pkg '$rules'")
|
|
||||||
.to(ArrayList(), null).exec()
|
|
||||||
Log.i(TAG, "set sepolicy result: ${result.code}")
|
|
||||||
return result.isSuccess
|
|
||||||
}
|
|
||||||
|
|
||||||
fun listAppProfileTemplates(): List<String> {
|
|
||||||
val shell = getRootShell()
|
|
||||||
return shell.newJob().add("${getKsuDaemonPath()} profile list-templates").to(ArrayList(), null)
|
|
||||||
.exec().out
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAppProfileTemplate(id: String): String {
|
|
||||||
val shell = getRootShell()
|
|
||||||
return shell.newJob().add("${getKsuDaemonPath()} profile get-template '${id}'")
|
|
||||||
.to(ArrayList(), null).exec().out.joinToString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAppProfileTemplate(id: String, template: String): Boolean {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val escapedTemplate = template.replace("\"", "\\\"")
|
|
||||||
val cmd = """${getKsuDaemonPath()} profile set-template "$id" "$escapedTemplate'""""
|
|
||||||
return shell.newJob().add(cmd)
|
|
||||||
.to(ArrayList(), null).exec().isSuccess
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteAppProfileTemplate(id: String): Boolean {
|
|
||||||
val shell = getRootShell()
|
|
||||||
return shell.newJob().add("${getKsuDaemonPath()} profile delete-template '${id}'")
|
|
||||||
.to(ArrayList(), null).exec().isSuccess
|
|
||||||
}
|
|
||||||
|
|
||||||
fun forceStopApp(packageName: String) {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val result = shell.newJob().add("am force-stop $packageName").exec()
|
|
||||||
Log.i(TAG, "force stop $packageName result: $result")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun launchApp(packageName: String) {
|
|
||||||
|
|
||||||
val shell = getRootShell()
|
|
||||||
val result =
|
|
||||||
shell.newJob()
|
|
||||||
.add("cmd package resolve-activity --brief $packageName | tail -n 1 | xargs cmd activity start-activity -n")
|
|
||||||
.exec()
|
|
||||||
Log.i(TAG, "launch $packageName result: $result")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun restartApp(packageName: String) {
|
|
||||||
forceStopApp(packageName)
|
|
||||||
launchApp(packageName)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSuSFSDaemonPath(): String {
|
|
||||||
return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libzakozakozako.so"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSuSFS(): String {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} support")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSuSFSVersion(): String {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} version")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSuSFSVariant(): String {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} variant")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
fun getSuSFSFeatures(): String {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} features")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun susfsSUS_SU_0(): String {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} sus_su 0")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun susfsSUS_SU_2(): String {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} sus_su 2")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun susfsSUS_SU_Mode(): String {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} sus_su mode")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getKpmmgrPath(): String {
|
|
||||||
return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libkpmmgr.so"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun loadKpmModule(path: String, args: String? = null): String {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val cmd = "${getKpmmgrPath()} load $path ${args ?: ""}"
|
|
||||||
return ShellUtils.fastCmd(shell, cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun unloadKpmModule(name: String): String {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val cmd = "${getKpmmgrPath()} unload $name"
|
|
||||||
return ShellUtils.fastCmd(shell, cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getKpmModuleCount(): Int {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val cmd = "${getKpmmgrPath()} num"
|
|
||||||
val result = ShellUtils.fastCmd(shell, cmd)
|
|
||||||
return result.trim().toIntOrNull() ?: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun runCmd(shell : Shell, cmd : String) : String {
|
|
||||||
return shell.newJob()
|
|
||||||
.add(cmd)
|
|
||||||
.to(mutableListOf<String>(), null)
|
|
||||||
.exec().out
|
|
||||||
.joinToString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun listKpmModules(): String {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val cmd = "${getKpmmgrPath()} list"
|
|
||||||
return try {
|
|
||||||
runCmd(shell, cmd).trim()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to list KPM modules", e)
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getKpmModuleInfo(name: String): String {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val cmd = "${getKpmmgrPath()} info $name"
|
|
||||||
return try {
|
|
||||||
runCmd(shell, cmd).trim()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to get KPM module info: $name", e)
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun controlKpmModule(name: String, args: String? = null): Int {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val cmd = """${getKpmmgrPath()} control $name "${args ?: ""}""""
|
|
||||||
val result = runCmd(shell, cmd)
|
|
||||||
return result.trim().toIntOrNull() ?: -1
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getKpmVersion(): String {
|
|
||||||
val shell = getRootShell()
|
|
||||||
val cmd = "${getKpmmgrPath()} version"
|
|
||||||
val result = ShellUtils.fastCmd(shell, cmd)
|
|
||||||
return result.trim()
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.util
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import android.system.Os
|
|
||||||
import com.topjohnwu.superuser.ShellUtils
|
|
||||||
import com.sukisu.ultra.Natives
|
|
||||||
import com.sukisu.ultra.ui.screen.getManagerVersion
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileWriter
|
|
||||||
import java.io.PrintWriter
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
fun getBugreportFile(context: Context): File {
|
|
||||||
|
|
||||||
val bugreportDir = File(context.cacheDir, "bugreport")
|
|
||||||
bugreportDir.mkdirs()
|
|
||||||
|
|
||||||
val dmesgFile = File(bugreportDir, "dmesg.txt")
|
|
||||||
val logcatFile = File(bugreportDir, "logcat.txt")
|
|
||||||
val tombstonesFile = File(bugreportDir, "tombstones.tar.gz")
|
|
||||||
val dropboxFile = File(bugreportDir, "dropbox.tar.gz")
|
|
||||||
val pstoreFile = File(bugreportDir, "pstore.tar.gz")
|
|
||||||
// Xiaomi/Readmi devices have diag in /data/vendor/diag
|
|
||||||
val diagFile = File(bugreportDir, "diag.tar.gz")
|
|
||||||
val oplusFile = File(bugreportDir, "oplus.tar.gz")
|
|
||||||
val bootlogFile = File(bugreportDir, "bootlog.tar.gz")
|
|
||||||
val mountsFile = File(bugreportDir, "mounts.txt")
|
|
||||||
val fileSystemsFile = File(bugreportDir, "filesystems.txt")
|
|
||||||
val adbFileTree = File(bugreportDir, "adb_tree.txt")
|
|
||||||
val adbFileDetails = File(bugreportDir, "adb_details.txt")
|
|
||||||
val ksuFileSize = File(bugreportDir, "ksu_size.txt")
|
|
||||||
val appListFile = File(bugreportDir, "packages.txt")
|
|
||||||
val propFile = File(bugreportDir, "props.txt")
|
|
||||||
val allowListFile = File(bugreportDir, "allowlist.bin")
|
|
||||||
val procModules = File(bugreportDir, "proc_modules.txt")
|
|
||||||
val bootConfig = File(bugreportDir, "boot_config.txt")
|
|
||||||
val kernelConfig = File(bugreportDir, "defconfig.gz")
|
|
||||||
|
|
||||||
val shell = getRootShell(true)
|
|
||||||
|
|
||||||
shell.newJob().add("dmesg > ${dmesgFile.absolutePath}").exec()
|
|
||||||
shell.newJob().add("logcat -d > ${logcatFile.absolutePath}").exec()
|
|
||||||
shell.newJob().add("tar -czf ${tombstonesFile.absolutePath} -C /data/tombstones .").exec()
|
|
||||||
shell.newJob().add("tar -czf ${dropboxFile.absolutePath} -C /data/system/dropbox .").exec()
|
|
||||||
shell.newJob().add("tar -czf ${pstoreFile.absolutePath} -C /sys/fs/pstore .").exec()
|
|
||||||
shell.newJob().add("tar -czf ${diagFile.absolutePath} -C /data/vendor/diag . --exclude=./minidump.gz").exec()
|
|
||||||
shell.newJob().add("tar -czf ${oplusFile.absolutePath} -C /mnt/oplus/op2/media/log/boot_log/ .").exec()
|
|
||||||
shell.newJob().add("tar -czf ${bootlogFile.absolutePath} -C /data/adb/ksu/log .").exec()
|
|
||||||
|
|
||||||
shell.newJob().add("cat /proc/1/mountinfo > ${mountsFile.absolutePath}").exec()
|
|
||||||
shell.newJob().add("cat /proc/filesystems > ${fileSystemsFile.absolutePath}").exec()
|
|
||||||
shell.newJob().add("busybox tree /data/adb > ${adbFileTree.absolutePath}").exec()
|
|
||||||
shell.newJob().add("ls -alRZ /data/adb > ${adbFileDetails.absolutePath}").exec()
|
|
||||||
shell.newJob().add("du -sh /data/adb/ksu/* > ${ksuFileSize.absolutePath}").exec()
|
|
||||||
shell.newJob().add("cp /data/system/packages.list ${appListFile.absolutePath}").exec()
|
|
||||||
shell.newJob().add("getprop > ${propFile.absolutePath}").exec()
|
|
||||||
shell.newJob().add("cp /data/adb/ksu/.allowlist ${allowListFile.absolutePath}").exec()
|
|
||||||
shell.newJob().add("cp /proc/modules ${procModules.absolutePath}").exec()
|
|
||||||
shell.newJob().add("cp /proc/bootconfig ${bootConfig.absolutePath}").exec()
|
|
||||||
shell.newJob().add("cp /proc/config.gz ${kernelConfig.absolutePath}").exec()
|
|
||||||
|
|
||||||
val selinux = ShellUtils.fastCmd(shell, "getenforce")
|
|
||||||
|
|
||||||
// basic information
|
|
||||||
val buildInfo = File(bugreportDir, "basic.txt")
|
|
||||||
PrintWriter(FileWriter(buildInfo)).use { pw ->
|
|
||||||
pw.println("Kernel: ${System.getProperty("os.version")}")
|
|
||||||
pw.println("BRAND: " + Build.BRAND)
|
|
||||||
pw.println("MODEL: " + Build.MODEL)
|
|
||||||
pw.println("PRODUCT: " + Build.PRODUCT)
|
|
||||||
pw.println("MANUFACTURER: " + Build.MANUFACTURER)
|
|
||||||
pw.println("SDK: " + Build.VERSION.SDK_INT)
|
|
||||||
pw.println("PREVIEW_SDK: " + Build.VERSION.PREVIEW_SDK_INT)
|
|
||||||
pw.println("FINGERPRINT: " + Build.FINGERPRINT)
|
|
||||||
pw.println("DEVICE: " + Build.DEVICE)
|
|
||||||
pw.println("Manager: " + getManagerVersion(context))
|
|
||||||
pw.println("SELinux: $selinux")
|
|
||||||
|
|
||||||
val uname = Os.uname()
|
|
||||||
pw.println("KernelRelease: ${uname.release}")
|
|
||||||
pw.println("KernelVersion: ${uname.version}")
|
|
||||||
pw.println("Machine: ${uname.machine}")
|
|
||||||
pw.println("Nodename: ${uname.nodename}")
|
|
||||||
pw.println("Sysname: ${uname.sysname}")
|
|
||||||
|
|
||||||
val ksuKernel = Natives.version
|
|
||||||
pw.println("KernelSU: $ksuKernel")
|
|
||||||
val safeMode = Natives.isSafeMode
|
|
||||||
pw.println("SafeMode: $safeMode")
|
|
||||||
val lkmMode = Natives.isLkmMode
|
|
||||||
pw.println("LKM: $lkmMode")
|
|
||||||
}
|
|
||||||
|
|
||||||
// modules
|
|
||||||
val modulesFile = File(bugreportDir, "modules.json")
|
|
||||||
modulesFile.writeText(listModules())
|
|
||||||
|
|
||||||
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH_mm")
|
|
||||||
val current = LocalDateTime.now().format(formatter)
|
|
||||||
|
|
||||||
val targetFile = File(context.cacheDir, "KernelSU_bugreport_${current}.tar.gz")
|
|
||||||
|
|
||||||
shell.newJob().add("tar czf ${targetFile.absolutePath} -C ${bugreportDir.absolutePath} .").exec()
|
|
||||||
shell.newJob().add("rm -rf ${bugreportDir.absolutePath}").exec()
|
|
||||||
shell.newJob().add("chmod 0644 ${targetFile.absolutePath}").exec()
|
|
||||||
|
|
||||||
return targetFile
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.util
|
|
||||||
|
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.material3.SnackbarDuration
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.SnackbarResult
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import java.io.BufferedReader
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStreamReader
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
object ModuleModify {
|
|
||||||
suspend fun showRestoreConfirmation(context: Context): Boolean {
|
|
||||||
val result = CompletableDeferred<Boolean>()
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
AlertDialog.Builder(context)
|
|
||||||
.setTitle(context.getString(R.string.restore_confirm_title))
|
|
||||||
.setMessage(context.getString(R.string.restore_confirm_message))
|
|
||||||
.setPositiveButton(context.getString(R.string.confirm)) { _, _ -> result.complete(true) }
|
|
||||||
.setNegativeButton(context.getString(R.string.cancel)) { _, _ -> result.complete(false) }
|
|
||||||
.setOnCancelListener { result.complete(false) }
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
return result.await()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun backupModules(context: Context, snackBarHost: SnackbarHostState, uri: Uri) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val busyboxPath = "/data/adb/ksu/bin/busybox"
|
|
||||||
val moduleDir = "/data/adb/modules"
|
|
||||||
|
|
||||||
// 直接将tar输出重定向到用户选择的文件
|
|
||||||
val command = """
|
|
||||||
cd "$moduleDir" &&
|
|
||||||
$busyboxPath tar -cz ./* > /proc/self/fd/1
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
|
|
||||||
|
|
||||||
// 直接将tar输出写入到用户选择的文件
|
|
||||||
context.contentResolver.openOutputStream(uri)?.use { output ->
|
|
||||||
process.inputStream.copyTo(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
val error = BufferedReader(InputStreamReader(process.errorStream)).readText()
|
|
||||||
if (process.exitValue() != 0) {
|
|
||||||
throw IOException(context.getString(R.string.command_execution_failed, error))
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
context.getString(R.string.backup_success),
|
|
||||||
duration = SnackbarDuration.Long
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("Backup", context.getString(R.string.backup_failed, ""), e)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
context.getString(R.string.backup_failed, e.message),
|
|
||||||
duration = SnackbarDuration.Long
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreModules(context: Context, snackBarHost: SnackbarHostState, uri: Uri) {
|
|
||||||
val userConfirmed = showRestoreConfirmation(context)
|
|
||||||
if (!userConfirmed) return
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val busyboxPath = "/data/adb/ksu/bin/busybox"
|
|
||||||
val moduleDir = "/data/adb/modules"
|
|
||||||
|
|
||||||
// 直接从用户选择的文件读取并解压
|
|
||||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "$busyboxPath tar -xz -C $moduleDir"))
|
|
||||||
|
|
||||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
|
||||||
input.copyTo(process.outputStream)
|
|
||||||
}
|
|
||||||
process.outputStream.close()
|
|
||||||
|
|
||||||
process.waitFor()
|
|
||||||
|
|
||||||
val error = BufferedReader(InputStreamReader(process.errorStream)).readText()
|
|
||||||
if (process.exitValue() != 0) {
|
|
||||||
throw IOException(context.getString(R.string.command_execution_failed, error))
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
val snackbarResult = snackBarHost.showSnackbar(
|
|
||||||
message = context.getString(R.string.restore_success),
|
|
||||||
actionLabel = context.getString(R.string.restart_now),
|
|
||||||
duration = SnackbarDuration.Long
|
|
||||||
)
|
|
||||||
if (snackbarResult == SnackbarResult.ActionPerformed) {
|
|
||||||
reboot()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("Restore", context.getString(R.string.restore_failed, ""), e)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
message = context.getString(
|
|
||||||
R.string.restore_failed,
|
|
||||||
e.message ?: context.getString(R.string.unknown_error)
|
|
||||||
),
|
|
||||||
duration = SnackbarDuration.Long
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun showAllowlistRestoreConfirmation(context: Context): Boolean {
|
|
||||||
val result = CompletableDeferred<Boolean>()
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
AlertDialog.Builder(context)
|
|
||||||
.setTitle(context.getString(R.string.allowlist_restore_confirm_title))
|
|
||||||
.setMessage(context.getString(R.string.allowlist_restore_confirm_message))
|
|
||||||
.setPositiveButton(context.getString(R.string.confirm)) { _, _ -> result.complete(true) }
|
|
||||||
.setNegativeButton(context.getString(R.string.cancel)) { _, _ -> result.complete(false) }
|
|
||||||
.setOnCancelListener { result.complete(false) }
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
return result.await()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun backupAllowlist(context: Context, snackBarHost: SnackbarHostState, uri: Uri) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val allowlistPath = "/data/adb/ksu/.allowlist"
|
|
||||||
|
|
||||||
// 直接复制文件到用户选择的位置
|
|
||||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat $allowlistPath"))
|
|
||||||
|
|
||||||
context.contentResolver.openOutputStream(uri)?.use { output ->
|
|
||||||
process.inputStream.copyTo(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
val error = BufferedReader(InputStreamReader(process.errorStream)).readText()
|
|
||||||
if (process.exitValue() != 0) {
|
|
||||||
throw IOException(context.getString(R.string.command_execution_failed, error))
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
context.getString(R.string.allowlist_backup_success),
|
|
||||||
duration = SnackbarDuration.Long
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("AllowlistBackup", context.getString(R.string.allowlist_backup_failed, ""), e)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
context.getString(R.string.allowlist_backup_failed, e.message),
|
|
||||||
duration = SnackbarDuration.Long
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreAllowlist(context: Context, snackBarHost: SnackbarHostState, uri: Uri) {
|
|
||||||
val userConfirmed = showAllowlistRestoreConfirmation(context)
|
|
||||||
if (!userConfirmed) return
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val allowlistPath = "/data/adb/ksu/.allowlist"
|
|
||||||
|
|
||||||
// 直接从用户选择的文件读取并写入到目标位置
|
|
||||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat > $allowlistPath"))
|
|
||||||
|
|
||||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
|
||||||
input.copyTo(process.outputStream)
|
|
||||||
}
|
|
||||||
process.outputStream.close()
|
|
||||||
|
|
||||||
process.waitFor()
|
|
||||||
|
|
||||||
val error = BufferedReader(InputStreamReader(process.errorStream)).readText()
|
|
||||||
if (process.exitValue() != 0) {
|
|
||||||
throw IOException(context.getString(R.string.command_execution_failed, error))
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
context.getString(R.string.allowlist_restore_success),
|
|
||||||
duration = SnackbarDuration.Long
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("AllowlistRestore", context.getString(R.string.allowlist_restore_failed, ""), e)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
snackBarHost.showSnackbar(
|
|
||||||
context.getString(R.string.allowlist_restore_failed, e.message),
|
|
||||||
duration = SnackbarDuration.Long
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberModuleBackupLauncher(
|
|
||||||
context: Context,
|
|
||||||
snackBarHost: SnackbarHostState,
|
|
||||||
scope: kotlinx.coroutines.CoroutineScope = rememberCoroutineScope()
|
|
||||||
) = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
|
||||||
) { result ->
|
|
||||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
|
||||||
result.data?.data?.let { uri ->
|
|
||||||
scope.launch {
|
|
||||||
backupModules(context, snackBarHost, uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberModuleRestoreLauncher(
|
|
||||||
context: Context,
|
|
||||||
snackBarHost: SnackbarHostState,
|
|
||||||
scope: kotlinx.coroutines.CoroutineScope = rememberCoroutineScope()
|
|
||||||
) = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
|
||||||
) { result ->
|
|
||||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
|
||||||
result.data?.data?.let { uri ->
|
|
||||||
scope.launch {
|
|
||||||
restoreModules(context, snackBarHost, uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberAllowlistBackupLauncher(
|
|
||||||
context: Context,
|
|
||||||
snackBarHost: SnackbarHostState,
|
|
||||||
scope: kotlinx.coroutines.CoroutineScope = rememberCoroutineScope()
|
|
||||||
) = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
|
||||||
) { result ->
|
|
||||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
|
||||||
result.data?.data?.let { uri ->
|
|
||||||
scope.launch {
|
|
||||||
backupAllowlist(context, snackBarHost, uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberAllowlistRestoreLauncher(
|
|
||||||
context: Context,
|
|
||||||
snackBarHost: SnackbarHostState,
|
|
||||||
scope: kotlinx.coroutines.CoroutineScope = rememberCoroutineScope()
|
|
||||||
) = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
|
||||||
) { result ->
|
|
||||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
|
||||||
result.data?.data?.let { uri ->
|
|
||||||
scope.launch {
|
|
||||||
restoreAllowlist(context, snackBarHost, uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createBackupIntent(): Intent {
|
|
||||||
return Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
type = "application/zip"
|
|
||||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
|
||||||
putExtra(Intent.EXTRA_TITLE, "modules_backup_$timestamp.zip")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createRestoreIntent(): Intent {
|
|
||||||
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
type = "application/zip"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createAllowlistBackupIntent(): Intent {
|
|
||||||
return Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
type = "application/octet-stream"
|
|
||||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
|
||||||
putExtra(Intent.EXTRA_TITLE, "ksu_allowlist_backup_$timestamp.dat")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createAllowlistRestoreIntent(): Intent {
|
|
||||||
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
type = "application/octet-stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reboot() {
|
|
||||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "reboot"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.util
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import com.topjohnwu.superuser.Shell
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun getSELinuxStatus(): String {
|
|
||||||
val shell = Shell.Builder.create().build("sh")
|
|
||||||
val list = ArrayList<String>()
|
|
||||||
|
|
||||||
val result = shell.use {
|
|
||||||
it.newJob().add("getenforce").to(list, list).exec()
|
|
||||||
}
|
|
||||||
|
|
||||||
val output = list.joinToString("\n").trim()
|
|
||||||
|
|
||||||
return if (result.isSuccess) {
|
|
||||||
when (output) {
|
|
||||||
"Enforcing" -> stringResource(R.string.selinux_status_enforcing)
|
|
||||||
"Permissive" -> stringResource(R.string.selinux_status_permissive)
|
|
||||||
"Disabled" -> stringResource(R.string.selinux_status_disabled)
|
|
||||||
else -> stringResource(R.string.selinux_status_unknown)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (output.contains("Permission denied")) {
|
|
||||||
stringResource(R.string.selinux_status_enforcing)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.selinux_status_unknown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.util.module
|
|
||||||
|
|
||||||
data class LatestVersionInfo(
|
|
||||||
val versionCode : Int = 0,
|
|
||||||
val downloadUrl : String = "",
|
|
||||||
val changelog : String = "",
|
|
||||||
val versionName: String = ""
|
|
||||||
)
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.viewmodel
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import com.sukisu.ultra.ui.util.*
|
|
||||||
|
|
||||||
class KpmViewModel : ViewModel() {
|
|
||||||
var moduleList by mutableStateOf(emptyList<ModuleInfo>())
|
|
||||||
private set
|
|
||||||
|
|
||||||
var search by mutableStateOf("")
|
|
||||||
internal set
|
|
||||||
|
|
||||||
var isRefreshing by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
|
|
||||||
var currentModuleDetail by mutableStateOf("")
|
|
||||||
private set
|
|
||||||
|
|
||||||
fun fetchModuleList() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
isRefreshing = true
|
|
||||||
try {
|
|
||||||
val moduleCount = getKpmModuleCount()
|
|
||||||
Log.d("KsuCli", "Module count: $moduleCount")
|
|
||||||
|
|
||||||
moduleList = getAllKpmModuleInfo()
|
|
||||||
|
|
||||||
// 获取 KPM 版本信息
|
|
||||||
val kpmVersion = getKpmVersion()
|
|
||||||
Log.d("KsuCli", "KPM Version: $kpmVersion")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("KsuCli", "获取模块列表失败", e)
|
|
||||||
} finally {
|
|
||||||
isRefreshing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getAllKpmModuleInfo(): List<ModuleInfo> {
|
|
||||||
val result = mutableListOf<ModuleInfo>()
|
|
||||||
try {
|
|
||||||
val str = listKpmModules()
|
|
||||||
val moduleNames = str
|
|
||||||
.split("\n")
|
|
||||||
.filter { it.isNotBlank() }
|
|
||||||
|
|
||||||
for (name in moduleNames) {
|
|
||||||
try {
|
|
||||||
val moduleInfo = parseModuleInfo(name)
|
|
||||||
moduleInfo?.let { result.add(it) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("KsuCli", "Error processing module $name", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("KsuCli", "Failed to get module list", e)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseModuleInfo(name: String): ModuleInfo? {
|
|
||||||
val info = getKpmModuleInfo(name)
|
|
||||||
if (info.isBlank()) return null
|
|
||||||
|
|
||||||
val properties = info.lineSequence()
|
|
||||||
.filter { line ->
|
|
||||||
val trimmed = line.trim()
|
|
||||||
trimmed.isNotEmpty() && !trimmed.startsWith("#")
|
|
||||||
}
|
|
||||||
.mapNotNull { line ->
|
|
||||||
line.split("=", limit = 2).let { parts ->
|
|
||||||
when (parts.size) {
|
|
||||||
2 -> parts[0].trim() to parts[1].trim()
|
|
||||||
1 -> parts[0].trim() to ""
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toMap()
|
|
||||||
|
|
||||||
return ModuleInfo(
|
|
||||||
id = name,
|
|
||||||
name = properties["name"] ?: name,
|
|
||||||
version = properties["version"] ?: "",
|
|
||||||
author = properties["author"] ?: "",
|
|
||||||
description = properties["description"] ?: "",
|
|
||||||
args = properties["args"] ?: "",
|
|
||||||
enabled = true,
|
|
||||||
hasAction = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadModuleDetail(moduleId: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
currentModuleDetail = withContext(Dispatchers.IO) {
|
|
||||||
getKpmModuleInfo(moduleId)
|
|
||||||
}
|
|
||||||
Log.d("KsuCli", "Module detail loaded: $currentModuleDetail")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("KsuCli", "Failed to load module detail", e)
|
|
||||||
currentModuleDetail = "Error: ${e.message}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var showInputDialog by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
|
|
||||||
var selectedModuleId by mutableStateOf<String?>(null)
|
|
||||||
private set
|
|
||||||
|
|
||||||
var inputArgs by mutableStateOf("")
|
|
||||||
private set
|
|
||||||
|
|
||||||
fun showInputDialog(moduleId: String) {
|
|
||||||
selectedModuleId = moduleId
|
|
||||||
showInputDialog = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hideInputDialog() {
|
|
||||||
showInputDialog = false
|
|
||||||
selectedModuleId = null
|
|
||||||
inputArgs = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateInputArgs(args: String) {
|
|
||||||
inputArgs = args
|
|
||||||
}
|
|
||||||
|
|
||||||
fun executeControl(): Int {
|
|
||||||
val moduleId = selectedModuleId ?: return -1
|
|
||||||
val result = controlKpmModule(moduleId, inputArgs)
|
|
||||||
hideInputDialog()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ModuleInfo(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val version: String,
|
|
||||||
val author: String,
|
|
||||||
val description: String,
|
|
||||||
val args: String,
|
|
||||||
val enabled: Boolean,
|
|
||||||
val hasAction: Boolean
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.viewmodel
|
|
||||||
|
|
||||||
import android.os.SystemClock
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import com.sukisu.ultra.ui.util.HanziToPinyin
|
|
||||||
import com.sukisu.ultra.ui.util.listModules
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.text.Collator
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class ModuleViewModel : ViewModel() {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "ModuleViewModel"
|
|
||||||
private var modules by mutableStateOf<List<ModuleInfo>>(emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
class ModuleInfo(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val author: String,
|
|
||||||
val version: String,
|
|
||||||
val versionCode: Int,
|
|
||||||
val description: String,
|
|
||||||
val enabled: Boolean,
|
|
||||||
val update: Boolean,
|
|
||||||
val remove: Boolean,
|
|
||||||
val updateJson: String,
|
|
||||||
val hasWebUi: Boolean,
|
|
||||||
val hasActionScript: Boolean,
|
|
||||||
val dirId: String, // real module id (dir name)
|
|
||||||
)
|
|
||||||
|
|
||||||
var isRefreshing by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
var search by mutableStateOf("")
|
|
||||||
|
|
||||||
var sortEnabledFirst by mutableStateOf(false)
|
|
||||||
var sortActionFirst by mutableStateOf(false)
|
|
||||||
val moduleList by derivedStateOf {
|
|
||||||
val comparator =
|
|
||||||
compareBy<ModuleInfo>(
|
|
||||||
{ if (sortEnabledFirst) !it.enabled else 0 },
|
|
||||||
{ if (sortActionFirst) !it.hasWebUi && !it.hasActionScript else 0 },
|
|
||||||
).thenBy(Collator.getInstance(Locale.getDefault()), ModuleInfo::id)
|
|
||||||
modules.filter {
|
|
||||||
it.id.contains(search, true) || it.name.contains(search, true) || HanziToPinyin.getInstance()
|
|
||||||
.toPinyinString(it.name).contains(search, true)
|
|
||||||
}.sortedWith(comparator).also {
|
|
||||||
isRefreshing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isNeedRefresh by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
|
|
||||||
fun markNeedRefresh() {
|
|
||||||
isNeedRefresh = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fetchModuleList() {
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
isRefreshing = true
|
|
||||||
|
|
||||||
val oldModuleList = modules
|
|
||||||
|
|
||||||
val start = SystemClock.elapsedRealtime()
|
|
||||||
|
|
||||||
kotlin.runCatching {
|
|
||||||
val result = listModules()
|
|
||||||
|
|
||||||
Log.i(TAG, "result: $result")
|
|
||||||
|
|
||||||
val array = JSONArray(result)
|
|
||||||
modules = (0 until array.length())
|
|
||||||
.asSequence()
|
|
||||||
.map { array.getJSONObject(it) }
|
|
||||||
.map { obj ->
|
|
||||||
ModuleInfo(
|
|
||||||
obj.getString("id"),
|
|
||||||
obj.optString("name"),
|
|
||||||
obj.optString("author", "Unknown"),
|
|
||||||
obj.optString("version", "Unknown"),
|
|
||||||
obj.optInt("versionCode", 0),
|
|
||||||
obj.optString("description"),
|
|
||||||
obj.getBoolean("enabled"),
|
|
||||||
obj.getBoolean("update"),
|
|
||||||
obj.getBoolean("remove"),
|
|
||||||
obj.optString("updateJson"),
|
|
||||||
obj.optBoolean("web"),
|
|
||||||
obj.optBoolean("action"),
|
|
||||||
obj.getString("dir_id"),
|
|
||||||
)
|
|
||||||
}.toList()
|
|
||||||
isNeedRefresh = false
|
|
||||||
}.onFailure { e ->
|
|
||||||
Log.e(TAG, "fetchModuleList: ", e)
|
|
||||||
isRefreshing = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// when both old and new is kotlin.collections.EmptyList
|
|
||||||
// moduleList update will don't trigger
|
|
||||||
if (oldModuleList === modules) {
|
|
||||||
isRefreshing = false
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i(TAG, "load cost: ${SystemClock.elapsedRealtime() - start}, modules: $modules")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun checkUpdate(m: ModuleInfo): Triple<String, String, String> {
|
|
||||||
val empty = Triple("", "", "")
|
|
||||||
if (m.updateJson.isEmpty() || m.remove || m.update || !m.enabled) {
|
|
||||||
return empty
|
|
||||||
}
|
|
||||||
// download updateJson
|
|
||||||
val result = kotlin.runCatching {
|
|
||||||
val url = m.updateJson
|
|
||||||
Log.i(TAG, "checkUpdate url: $url")
|
|
||||||
val response = okhttp3.OkHttpClient()
|
|
||||||
.newCall(
|
|
||||||
okhttp3.Request.Builder()
|
|
||||||
.url(url)
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
Log.d(TAG, "checkUpdate code: ${response.code}")
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
response.body?.string() ?: ""
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}.getOrDefault("")
|
|
||||||
Log.i(TAG, "checkUpdate result: $result")
|
|
||||||
|
|
||||||
if (result.isEmpty()) {
|
|
||||||
return empty
|
|
||||||
}
|
|
||||||
|
|
||||||
val updateJson = kotlin.runCatching {
|
|
||||||
JSONObject(result)
|
|
||||||
}.getOrNull() ?: return empty
|
|
||||||
|
|
||||||
val version = updateJson.optString("version", "")
|
|
||||||
val versionCode = updateJson.optInt("versionCode", 0)
|
|
||||||
val zipUrl = updateJson.optString("zipUrl", "")
|
|
||||||
val changelog = updateJson.optString("changelog", "")
|
|
||||||
if (versionCode <= m.versionCode || zipUrl.isEmpty()) {
|
|
||||||
return empty
|
|
||||||
}
|
|
||||||
|
|
||||||
return Triple(zipUrl, version, changelog)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.viewmodel
|
|
||||||
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.ServiceConnection
|
|
||||||
import android.content.pm.ApplicationInfo
|
|
||||||
import android.content.pm.PackageInfo
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.Parcelable
|
|
||||||
import android.os.SystemClock
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import com.topjohnwu.superuser.Shell
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
import com.sukisu.zako.IKsuInterface
|
|
||||||
import com.sukisu.ultra.Natives
|
|
||||||
import com.sukisu.ultra.ksuApp
|
|
||||||
import com.sukisu.ultra.ui.KsuService
|
|
||||||
import com.sukisu.ultra.ui.util.HanziToPinyin
|
|
||||||
import com.sukisu.ultra.ui.util.KsuCli
|
|
||||||
import java.text.Collator
|
|
||||||
import java.util.*
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.suspendCoroutine
|
|
||||||
|
|
||||||
class SuperUserViewModel : ViewModel() {
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "SuperUserViewModel"
|
|
||||||
private var apps by mutableStateOf<List<AppInfo>>(emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class AppInfo(
|
|
||||||
val label: String,
|
|
||||||
val packageInfo: PackageInfo,
|
|
||||||
val profile: Natives.Profile?,
|
|
||||||
) : Parcelable {
|
|
||||||
val packageName: String
|
|
||||||
get() = packageInfo.packageName
|
|
||||||
val uid: Int
|
|
||||||
get() = packageInfo.applicationInfo!!.uid
|
|
||||||
|
|
||||||
val allowSu: Boolean
|
|
||||||
get() = profile != null && profile.allowSu
|
|
||||||
val hasCustomProfile: Boolean
|
|
||||||
get() {
|
|
||||||
if (profile == null) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return if (profile.allowSu) {
|
|
||||||
!profile.rootUseDefault
|
|
||||||
} else {
|
|
||||||
!profile.nonRootUseDefault
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var search by mutableStateOf("")
|
|
||||||
var showSystemApps by mutableStateOf(false)
|
|
||||||
var isRefreshing by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
|
|
||||||
// 批量操作相关状态
|
|
||||||
var showBatchActions by mutableStateOf(false)
|
|
||||||
internal set
|
|
||||||
var selectedApps by mutableStateOf<Set<String>>(emptySet())
|
|
||||||
internal set
|
|
||||||
|
|
||||||
private val sortedList by derivedStateOf {
|
|
||||||
val comparator = compareBy<AppInfo> {
|
|
||||||
when {
|
|
||||||
it.allowSu -> 0
|
|
||||||
it.hasCustomProfile -> 1
|
|
||||||
else -> 2
|
|
||||||
}
|
|
||||||
}.then(compareBy(Collator.getInstance(Locale.getDefault()), AppInfo::label))
|
|
||||||
apps.sortedWith(comparator).also {
|
|
||||||
isRefreshing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appList by derivedStateOf {
|
|
||||||
sortedList.filter {
|
|
||||||
it.label.contains(search, true) || it.packageName.contains(
|
|
||||||
search,
|
|
||||||
true
|
|
||||||
) || HanziToPinyin.getInstance()
|
|
||||||
.toPinyinString(it.label).contains(search, true)
|
|
||||||
}.filter {
|
|
||||||
it.uid == 2000 || showSystemApps || it.packageInfo.applicationInfo!!.flags.and(ApplicationInfo.FLAG_SYSTEM) == 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换批量操作模式
|
|
||||||
fun toggleBatchMode() {
|
|
||||||
showBatchActions = !showBatchActions
|
|
||||||
if (!showBatchActions) {
|
|
||||||
clearSelection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换应用选择状态
|
|
||||||
fun toggleAppSelection(packageName: String) {
|
|
||||||
selectedApps = if (selectedApps.contains(packageName)) {
|
|
||||||
selectedApps - packageName
|
|
||||||
} else {
|
|
||||||
selectedApps + packageName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除所有选择
|
|
||||||
fun clearSelection() {
|
|
||||||
selectedApps = emptySet()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量更新权限
|
|
||||||
suspend fun updateBatchPermissions(allowSu: Boolean) {
|
|
||||||
selectedApps.forEach { packageName ->
|
|
||||||
val app = apps.find { it.packageName == packageName }
|
|
||||||
app?.let {
|
|
||||||
val profile = Natives.getAppProfile(packageName, it.uid)
|
|
||||||
val updatedProfile = profile.copy(allowSu = allowSu)
|
|
||||||
if (Natives.setAppProfile(updatedProfile)) {
|
|
||||||
apps = apps.map { app ->
|
|
||||||
if (app.packageName == packageName) {
|
|
||||||
app.copy(profile = updatedProfile)
|
|
||||||
} else {
|
|
||||||
app
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clearSelection()
|
|
||||||
showBatchActions = false // 批量操作完成后退出批量模式
|
|
||||||
fetchAppList() // 刷新列表以显示最新状态
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun connectKsuService(
|
|
||||||
onDisconnect: () -> Unit = {}
|
|
||||||
): Pair<IBinder, ServiceConnection> = suspendCoroutine { continuation ->
|
|
||||||
val connection = object : ServiceConnection {
|
|
||||||
override fun onServiceDisconnected(name: ComponentName?) {
|
|
||||||
onDisconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
|
||||||
continuation.resume(binder as IBinder to this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val intent = Intent(ksuApp, KsuService::class.java)
|
|
||||||
|
|
||||||
val task = KsuService.bindOrTask(
|
|
||||||
intent,
|
|
||||||
Shell.EXECUTOR,
|
|
||||||
connection,
|
|
||||||
)
|
|
||||||
val shell = KsuCli.SHELL
|
|
||||||
task?.let { it1 -> shell.execTask(it1) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopKsuService() {
|
|
||||||
val intent = Intent(ksuApp, KsuService::class.java)
|
|
||||||
KsuService.stop(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun fetchAppList() {
|
|
||||||
isRefreshing = true
|
|
||||||
|
|
||||||
val result = connectKsuService {
|
|
||||||
Log.w(TAG, "KsuService disconnected")
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val pm = ksuApp.packageManager
|
|
||||||
val start = SystemClock.elapsedRealtime()
|
|
||||||
|
|
||||||
val binder = result.first
|
|
||||||
val allPackages = IKsuInterface.Stub.asInterface(binder).getPackages(0)
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
stopKsuService()
|
|
||||||
}
|
|
||||||
|
|
||||||
val packages = allPackages.list
|
|
||||||
|
|
||||||
apps = packages.map {
|
|
||||||
val appInfo = it.applicationInfo
|
|
||||||
val uid = appInfo!!.uid
|
|
||||||
val profile = Natives.getAppProfile(it.packageName, uid)
|
|
||||||
AppInfo(
|
|
||||||
label = appInfo.loadLabel(pm).toString(),
|
|
||||||
packageInfo = it,
|
|
||||||
profile = profile,
|
|
||||||
)
|
|
||||||
}.filter { it.packageName != ksuApp.packageName }
|
|
||||||
Log.i(TAG, "load cost: ${SystemClock.elapsedRealtime() - start}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.viewmodel
|
|
||||||
|
|
||||||
import android.os.Parcelable
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
import com.sukisu.ultra.Natives
|
|
||||||
import com.sukisu.ultra.profile.Capabilities
|
|
||||||
import com.sukisu.ultra.profile.Groups
|
|
||||||
import com.sukisu.ultra.ui.util.getAppProfileTemplate
|
|
||||||
import com.sukisu.ultra.ui.util.listAppProfileTemplates
|
|
||||||
import com.sukisu.ultra.ui.util.setAppProfileTemplate
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.text.Collator
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author weishu
|
|
||||||
* @date 2023/10/20.
|
|
||||||
*/
|
|
||||||
const val TEMPLATE_INDEX_URL = "https://kernelsu.org/templates/index.json"
|
|
||||||
const val TEMPLATE_URL = "https://kernelsu.org/templates/%s"
|
|
||||||
|
|
||||||
const val TAG = "TemplateViewModel"
|
|
||||||
|
|
||||||
class TemplateViewModel : ViewModel() {
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private var templates by mutableStateOf<List<TemplateInfo>>(emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class TemplateInfo(
|
|
||||||
val id: String = "",
|
|
||||||
val name: String = "",
|
|
||||||
val description: String = "",
|
|
||||||
val author: String = "",
|
|
||||||
val local: Boolean = true,
|
|
||||||
|
|
||||||
val namespace: Int = Natives.Profile.Namespace.INHERITED.ordinal,
|
|
||||||
val uid: Int = Natives.ROOT_UID,
|
|
||||||
val gid: Int = Natives.ROOT_GID,
|
|
||||||
val groups: List<Int> = mutableListOf(),
|
|
||||||
val capabilities: List<Int> = mutableListOf(),
|
|
||||||
val context: String = Natives.KERNEL_SU_DOMAIN,
|
|
||||||
val rules: List<String> = mutableListOf(),
|
|
||||||
) : Parcelable
|
|
||||||
|
|
||||||
var isRefreshing by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
|
|
||||||
val templateList by derivedStateOf {
|
|
||||||
val comparator = compareBy(TemplateInfo::local).reversed().then(
|
|
||||||
compareBy(
|
|
||||||
Collator.getInstance(Locale.getDefault()), TemplateInfo::id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
templates.sortedWith(comparator).apply {
|
|
||||||
isRefreshing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun fetchTemplates(sync: Boolean = false) {
|
|
||||||
isRefreshing = true
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val localTemplateIds = listAppProfileTemplates()
|
|
||||||
Log.i(TAG, "localTemplateIds: $localTemplateIds")
|
|
||||||
if (localTemplateIds.isEmpty() || sync) {
|
|
||||||
// if no templates, fetch remote templates
|
|
||||||
fetchRemoteTemplates()
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch templates again
|
|
||||||
templates = listAppProfileTemplates().mapNotNull(::getTemplateInfoById)
|
|
||||||
|
|
||||||
isRefreshing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun importTemplates(
|
|
||||||
templates: String,
|
|
||||||
onSuccess: suspend () -> Unit,
|
|
||||||
onFailure: suspend (String) -> Unit
|
|
||||||
) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
runCatching {
|
|
||||||
JSONArray(templates)
|
|
||||||
}.getOrElse {
|
|
||||||
runCatching {
|
|
||||||
val json = JSONObject(templates)
|
|
||||||
JSONArray().apply { put(json) }
|
|
||||||
}.getOrElse {
|
|
||||||
onFailure("invalid templates: $templates")
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
}.let {
|
|
||||||
0.until(it.length()).forEach { i ->
|
|
||||||
runCatching {
|
|
||||||
val template = it.getJSONObject(i)
|
|
||||||
val id = template.getString("id")
|
|
||||||
template.put("local", true)
|
|
||||||
setAppProfileTemplate(id, template.toString())
|
|
||||||
}.onFailure { e ->
|
|
||||||
Log.e(TAG, "ignore invalid template: $it", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onSuccess()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun exportTemplates(onTemplateEmpty: () -> Unit, callback: (String) -> Unit) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val templates = listAppProfileTemplates().mapNotNull(::getTemplateInfoById).filter {
|
|
||||||
it.local
|
|
||||||
}
|
|
||||||
templates.ifEmpty {
|
|
||||||
onTemplateEmpty()
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
JSONArray(templates.map {
|
|
||||||
it.toJSON()
|
|
||||||
}).toString().let(callback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fetchRemoteTemplates() {
|
|
||||||
runCatching {
|
|
||||||
val client: OkHttpClient = OkHttpClient.Builder()
|
|
||||||
.connectTimeout(5, TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(5, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(10, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
client.newCall(
|
|
||||||
Request.Builder().url(TEMPLATE_INDEX_URL).build()
|
|
||||||
).execute().use { response ->
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val remoteTemplateIds = JSONArray(response.body!!.string())
|
|
||||||
Log.i(TAG, "fetchRemoteTemplates: $remoteTemplateIds")
|
|
||||||
0.until(remoteTemplateIds.length()).forEach { i ->
|
|
||||||
val id = remoteTemplateIds.getString(i)
|
|
||||||
Log.i(TAG, "fetch template: $id")
|
|
||||||
val templateJson = client.newCall(
|
|
||||||
Request.Builder().url(TEMPLATE_URL.format(id)).build()
|
|
||||||
).runCatching {
|
|
||||||
execute().use { response ->
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
response.body!!.string()
|
|
||||||
}
|
|
||||||
}.getOrNull() ?: return@forEach
|
|
||||||
Log.i(TAG, "template: $templateJson")
|
|
||||||
|
|
||||||
// validate remote template
|
|
||||||
runCatching {
|
|
||||||
val json = JSONObject(templateJson)
|
|
||||||
fromJSON(json)?.let {
|
|
||||||
// force local template
|
|
||||||
json.put("local", false)
|
|
||||||
setAppProfileTemplate(id, json.toString())
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
Log.e(TAG, "ignore invalid template: $it", it)
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.onFailure { Log.e(TAG, "fetchRemoteTemplates: $it", it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
private fun <T, R> JSONArray.mapCatching(
|
|
||||||
transform: (T) -> R, onFail: (Throwable) -> Unit
|
|
||||||
): List<R> {
|
|
||||||
return List(length()) { i -> get(i) as T }.mapNotNull { element ->
|
|
||||||
runCatching {
|
|
||||||
transform(element)
|
|
||||||
}.onFailure(onFail).getOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <reified T : Enum<T>> getEnumOrdinals(
|
|
||||||
jsonArray: JSONArray?, enumClass: Class<T>
|
|
||||||
): List<T> {
|
|
||||||
return jsonArray?.mapCatching<String, T>({ name ->
|
|
||||||
enumValueOf(name.uppercase())
|
|
||||||
}, {
|
|
||||||
Log.e(TAG, "ignore invalid enum ${enumClass.simpleName}: $it", it)
|
|
||||||
}).orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getTemplateInfoById(id: String): TemplateViewModel.TemplateInfo? {
|
|
||||||
return runCatching {
|
|
||||||
fromJSON(JSONObject(getAppProfileTemplate(id)))
|
|
||||||
}.onFailure {
|
|
||||||
Log.e(TAG, "ignore invalid template: $it", it)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getLocaleString(json: JSONObject, key: String): String {
|
|
||||||
val fallback = json.getString(key)
|
|
||||||
val locale = Locale.getDefault()
|
|
||||||
val localeKey = "${locale.language}_${locale.country}"
|
|
||||||
json.optJSONObject("locales")?.let {
|
|
||||||
// check locale first
|
|
||||||
it.optJSONObject(localeKey)?.let { json->
|
|
||||||
return json.optString(key, fallback)
|
|
||||||
}
|
|
||||||
// fallback to language
|
|
||||||
it.optJSONObject(locale.language)?.let { json->
|
|
||||||
return json.optString(key, fallback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fromJSON(templateJson: JSONObject): TemplateViewModel.TemplateInfo? {
|
|
||||||
return runCatching {
|
|
||||||
val groupsJsonArray = templateJson.optJSONArray("groups")
|
|
||||||
val capabilitiesJsonArray = templateJson.optJSONArray("capabilities")
|
|
||||||
val context = templateJson.optString("context").takeIf { it.isNotEmpty() }
|
|
||||||
?: Natives.KERNEL_SU_DOMAIN
|
|
||||||
val namespace = templateJson.optString("namespace").takeIf { it.isNotEmpty() }
|
|
||||||
?: Natives.Profile.Namespace.INHERITED.name
|
|
||||||
|
|
||||||
val rulesJsonArray = templateJson.optJSONArray("rules")
|
|
||||||
val templateInfo = TemplateViewModel.TemplateInfo(
|
|
||||||
id = templateJson.getString("id"),
|
|
||||||
name = getLocaleString(templateJson, "name"),
|
|
||||||
description = getLocaleString(templateJson, "description"),
|
|
||||||
author = templateJson.optString("author"),
|
|
||||||
local = templateJson.optBoolean("local"),
|
|
||||||
namespace = Natives.Profile.Namespace.valueOf(
|
|
||||||
namespace.uppercase()
|
|
||||||
).ordinal,
|
|
||||||
uid = templateJson.optInt("uid", Natives.ROOT_UID),
|
|
||||||
gid = templateJson.optInt("gid", Natives.ROOT_GID),
|
|
||||||
groups = getEnumOrdinals(groupsJsonArray, Groups::class.java).map { it.gid },
|
|
||||||
capabilities = getEnumOrdinals(
|
|
||||||
capabilitiesJsonArray, Capabilities::class.java
|
|
||||||
).map { it.cap },
|
|
||||||
context = context,
|
|
||||||
rules = rulesJsonArray?.mapCatching<String, String>({ it }, {
|
|
||||||
Log.e(TAG, "ignore invalid rule: $it", it)
|
|
||||||
}).orEmpty()
|
|
||||||
)
|
|
||||||
templateInfo
|
|
||||||
}.onFailure {
|
|
||||||
Log.e(TAG, "ignore invalid template: $it", it)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun TemplateViewModel.TemplateInfo.toJSON(): JSONObject {
|
|
||||||
val template = this
|
|
||||||
return JSONObject().apply {
|
|
||||||
|
|
||||||
put("id", template.id)
|
|
||||||
put("name", template.name.ifBlank { template.id })
|
|
||||||
put("description", template.description.ifBlank { template.id })
|
|
||||||
if (template.author.isNotEmpty()) {
|
|
||||||
put("author", template.author)
|
|
||||||
}
|
|
||||||
put("namespace", Natives.Profile.Namespace.entries[template.namespace].name)
|
|
||||||
put("uid", template.uid)
|
|
||||||
put("gid", template.gid)
|
|
||||||
|
|
||||||
if (template.groups.isNotEmpty()) {
|
|
||||||
put("groups", JSONArray(
|
|
||||||
Groups.entries.filter {
|
|
||||||
template.groups.contains(it.gid)
|
|
||||||
}.map {
|
|
||||||
it.name
|
|
||||||
}
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (template.capabilities.isNotEmpty()) {
|
|
||||||
put("capabilities", JSONArray(
|
|
||||||
Capabilities.entries.filter {
|
|
||||||
template.capabilities.contains(it.cap)
|
|
||||||
}.map {
|
|
||||||
it.name
|
|
||||||
}
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (template.context.isNotEmpty()) {
|
|
||||||
put("context", template.context)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (template.rules.isNotEmpty()) {
|
|
||||||
put("rules", JSONArray(template.rules))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("unused")
|
|
||||||
fun generateTemplates() {
|
|
||||||
val templateJson = JSONObject()
|
|
||||||
templateJson.put("id", "com.example")
|
|
||||||
templateJson.put("name", "Example")
|
|
||||||
templateJson.put("description", "This is an example template")
|
|
||||||
templateJson.put("local", true)
|
|
||||||
templateJson.put("namespace", Natives.Profile.Namespace.INHERITED.name)
|
|
||||||
templateJson.put("uid", 0)
|
|
||||||
templateJson.put("gid", 0)
|
|
||||||
|
|
||||||
templateJson.put("groups", JSONArray().apply { put(Groups.INET.name) })
|
|
||||||
templateJson.put("capabilities", JSONArray().apply { put(Capabilities.CAP_NET_RAW.name) })
|
|
||||||
templateJson.put("context", "u:r:su:s0")
|
|
||||||
Log.i(TAG, "$templateJson")
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2023 The Android Open Source Project
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.sukisu.ultra.ui.webui;
|
|
||||||
|
|
||||||
import java.net.URLConnection;
|
|
||||||
|
|
||||||
class MimeUtil {
|
|
||||||
|
|
||||||
public static String getMimeFromFileName(String fileName) {
|
|
||||||
if (fileName == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copying the logic and mapping that Chromium follows.
|
|
||||||
// First we check against the OS (this is a limited list by default)
|
|
||||||
// but app developers can extend this.
|
|
||||||
// We then check against a list of hardcoded mime types above if the
|
|
||||||
// OS didn't provide a result.
|
|
||||||
String mimeType = URLConnection.guessContentTypeFromName(fileName);
|
|
||||||
|
|
||||||
if (mimeType != null) {
|
|
||||||
return mimeType;
|
|
||||||
}
|
|
||||||
|
|
||||||
return guessHardcodedMime(fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// We should keep this map in sync with the lists under
|
|
||||||
// //net/base/mime_util.cc in Chromium.
|
|
||||||
// A bunch of the mime types don't really apply to Android land
|
|
||||||
// like word docs so feel free to filter out where necessary.
|
|
||||||
private static String guessHardcodedMime(String fileName) {
|
|
||||||
int finalFullStop = fileName.lastIndexOf('.');
|
|
||||||
if (finalFullStop == -1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final String extension = fileName.substring(finalFullStop + 1).toLowerCase();
|
|
||||||
|
|
||||||
return switch (extension) {
|
|
||||||
case "webm" -> "video/webm";
|
|
||||||
case "mpeg", "mpg" -> "video/mpeg";
|
|
||||||
case "mp3" -> "audio/mpeg";
|
|
||||||
case "wasm" -> "application/wasm";
|
|
||||||
case "xhtml", "xht", "xhtm" -> "application/xhtml+xml";
|
|
||||||
case "flac" -> "audio/flac";
|
|
||||||
case "ogg", "oga", "opus" -> "audio/ogg";
|
|
||||||
case "wav" -> "audio/wav";
|
|
||||||
case "m4a" -> "audio/x-m4a";
|
|
||||||
case "gif" -> "image/gif";
|
|
||||||
case "jpeg", "jpg", "jfif", "pjpeg", "pjp" -> "image/jpeg";
|
|
||||||
case "png" -> "image/png";
|
|
||||||
case "apng" -> "image/apng";
|
|
||||||
case "svg", "svgz" -> "image/svg+xml";
|
|
||||||
case "webp" -> "image/webp";
|
|
||||||
case "mht", "mhtml" -> "multipart/related";
|
|
||||||
case "css" -> "text/css";
|
|
||||||
case "html", "htm", "shtml", "shtm", "ehtml" -> "text/html";
|
|
||||||
case "js", "mjs" -> "application/javascript";
|
|
||||||
case "xml" -> "text/xml";
|
|
||||||
case "mp4", "m4v" -> "video/mp4";
|
|
||||||
case "ogv", "ogm" -> "video/ogg";
|
|
||||||
case "ico" -> "image/x-icon";
|
|
||||||
case "woff" -> "application/font-woff";
|
|
||||||
case "gz", "tgz" -> "application/gzip";
|
|
||||||
case "json" -> "application/json";
|
|
||||||
case "pdf" -> "application/pdf";
|
|
||||||
case "zip" -> "application/zip";
|
|
||||||
case "bmp" -> "image/bmp";
|
|
||||||
case "tiff", "tif" -> "image/tiff";
|
|
||||||
default -> null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.webui;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.util.Log;
|
|
||||||
import android.webkit.WebResourceResponse;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.WorkerThread;
|
|
||||||
import androidx.webkit.WebViewAssetLoader;
|
|
||||||
|
|
||||||
import com.topjohnwu.superuser.Shell;
|
|
||||||
import com.topjohnwu.superuser.io.SuFile;
|
|
||||||
import com.topjohnwu.superuser.io.SuFileInputStream;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.util.zip.GZIPInputStream;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handler class to open files from file system by root access
|
|
||||||
* For more information about android storage please refer to
|
|
||||||
* <a href="https://developer.android.com/guide/topics/data/data-storage">Android Developers
|
|
||||||
* Docs: Data and file storage overview</a>.
|
|
||||||
* <p class="note">
|
|
||||||
* To avoid leaking user or app data to the web, make sure to choose {@code directory}
|
|
||||||
* carefully, and assume any file under this directory could be accessed by any web page subject
|
|
||||||
* to same-origin rules.
|
|
||||||
* <p>
|
|
||||||
* A typical usage would be like:
|
|
||||||
* <pre class="prettyprint">
|
|
||||||
* File publicDir = new File(context.getFilesDir(), "public");
|
|
||||||
* // Host "files/public/" in app's data directory under:
|
|
||||||
* // http://appassets.androidplatform.net/public/...
|
|
||||||
* WebViewAssetLoader assetLoader = new WebViewAssetLoader.Builder()
|
|
||||||
* .addPathHandler("/public/", new InternalStoragePathHandler(context, publicDir))
|
|
||||||
* .build();
|
|
||||||
* </pre>
|
|
||||||
*/
|
|
||||||
public final class SuFilePathHandler implements WebViewAssetLoader.PathHandler {
|
|
||||||
private static final String TAG = "SuFilePathHandler";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default value to be used as MIME type if guessing MIME type failed.
|
|
||||||
*/
|
|
||||||
public static final String DEFAULT_MIME_TYPE = "text/plain";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forbidden subdirectories of {@link Context#getDataDir} that cannot be exposed by this
|
|
||||||
* handler. They are forbidden as they often contain sensitive information.
|
|
||||||
* <p class="note">
|
|
||||||
* Note: Any future addition to this list will be considered breaking changes to the API.
|
|
||||||
*/
|
|
||||||
private static final String[] FORBIDDEN_DATA_DIRS =
|
|
||||||
new String[] {"/data/data", "/data/system"};
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
private final File mDirectory;
|
|
||||||
|
|
||||||
private final Shell mShell;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates PathHandler for app's internal storage.
|
|
||||||
* The directory to be exposed must be inside either the application's internal data
|
|
||||||
* directory {@link Context#getDataDir} or cache directory {@link Context#getCacheDir}.
|
|
||||||
* External storage is not supported for security reasons, as other apps with
|
|
||||||
* {@link android.Manifest.permission#WRITE_EXTERNAL_STORAGE} may be able to modify the
|
|
||||||
* files.
|
|
||||||
* <p>
|
|
||||||
* Exposing the entire data or cache directory is not permitted, to avoid accidentally
|
|
||||||
* exposing sensitive application files to the web. Certain existing subdirectories of
|
|
||||||
* {@link Context#getDataDir} are also not permitted as they are often sensitive.
|
|
||||||
* These files are ({@code "app_webview/"}, {@code "databases/"}, {@code "lib/"},
|
|
||||||
* {@code "shared_prefs/"} and {@code "code_cache/"}).
|
|
||||||
* <p>
|
|
||||||
* The application should typically use a dedicated subdirectory for the files it intends to
|
|
||||||
* expose and keep them separate from other files.
|
|
||||||
*
|
|
||||||
* @param context {@link Context} that is used to access app's internal storage.
|
|
||||||
* @param directory the absolute path of the exposed app internal storage directory from
|
|
||||||
* which files can be loaded.
|
|
||||||
* @throws IllegalArgumentException if the directory is not allowed.
|
|
||||||
*/
|
|
||||||
public SuFilePathHandler(@NonNull Context context, @NonNull File directory, Shell rootShell) {
|
|
||||||
try {
|
|
||||||
mDirectory = new File(getCanonicalDirPath(directory));
|
|
||||||
if (!isAllowedInternalStorageDir(context)) {
|
|
||||||
throw new IllegalArgumentException("The given directory \"" + directory
|
|
||||||
+ "\" doesn't exist under an allowed app internal storage directory");
|
|
||||||
}
|
|
||||||
mShell = rootShell;
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"Failed to resolve the canonical path for the given directory: "
|
|
||||||
+ directory.getPath(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isAllowedInternalStorageDir(@NonNull Context context) throws IOException {
|
|
||||||
String dir = getCanonicalDirPath(mDirectory);
|
|
||||||
|
|
||||||
for (String forbiddenPath : FORBIDDEN_DATA_DIRS) {
|
|
||||||
if (dir.startsWith(forbiddenPath)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the requested file from the exposed data directory.
|
|
||||||
* <p>
|
|
||||||
* The matched prefix path used shouldn't be a prefix of a real web path. Thus, if the
|
|
||||||
* requested file cannot be found or is outside the mounted directory a
|
|
||||||
* {@link WebResourceResponse} object with a {@code null} {@link InputStream} will be
|
|
||||||
* returned instead of {@code null}. This saves the time of falling back to network and
|
|
||||||
* trying to resolve a path that doesn't exist. A {@link WebResourceResponse} with
|
|
||||||
* {@code null} {@link InputStream} will be received as an HTTP response with status code
|
|
||||||
* {@code 404} and no body.
|
|
||||||
* <p class="note">
|
|
||||||
* The MIME type for the file will be determined from the file's extension using
|
|
||||||
* {@link java.net.URLConnection#guessContentTypeFromName}. Developers should ensure that
|
|
||||||
* files are named using standard file extensions. If the file does not have a
|
|
||||||
* recognised extension, {@code "text/plain"} will be used by default.
|
|
||||||
*
|
|
||||||
* @param path the suffix path to be handled.
|
|
||||||
* @return {@link WebResourceResponse} for the requested file.
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
@WorkerThread
|
|
||||||
@NonNull
|
|
||||||
public WebResourceResponse handle(@NonNull String path) {
|
|
||||||
try {
|
|
||||||
File file = getCanonicalFileIfChild(mDirectory, path);
|
|
||||||
if (file != null) {
|
|
||||||
InputStream is = openFile(file, mShell);
|
|
||||||
String mimeType = guessMimeType(path);
|
|
||||||
return new WebResourceResponse(mimeType, null, is);
|
|
||||||
} else {
|
|
||||||
Log.e(TAG, String.format(
|
|
||||||
"The requested file: %s is outside the mounted directory: %s", path,
|
|
||||||
mDirectory));
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
Log.e(TAG, "Error opening the requested path: " + path, e);
|
|
||||||
}
|
|
||||||
return new WebResourceResponse(null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String getCanonicalDirPath(@NonNull File file) throws IOException {
|
|
||||||
String canonicalPath = file.getCanonicalPath();
|
|
||||||
if (!canonicalPath.endsWith("/")) canonicalPath += "/";
|
|
||||||
return canonicalPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static File getCanonicalFileIfChild(@NonNull File parent, @NonNull String child)
|
|
||||||
throws IOException {
|
|
||||||
String parentCanonicalPath = getCanonicalDirPath(parent);
|
|
||||||
String childCanonicalPath = new File(parent, child).getCanonicalPath();
|
|
||||||
if (childCanonicalPath.startsWith(parentCanonicalPath)) {
|
|
||||||
return new File(childCanonicalPath);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
private static InputStream handleSvgzStream(@NonNull String path,
|
|
||||||
@NonNull InputStream stream) throws IOException {
|
|
||||||
return path.endsWith(".svgz") ? new GZIPInputStream(stream) : stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static InputStream openFile(@NonNull File file, @NonNull Shell shell) throws IOException {
|
|
||||||
SuFile suFile = new SuFile(file.getAbsolutePath());
|
|
||||||
suFile.setShell(shell);
|
|
||||||
InputStream fis = SuFileInputStream.open(suFile);
|
|
||||||
return handleSvgzStream(file.getPath(), fis);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use {@link MimeUtil#getMimeFromFileName} to guess MIME type or return the
|
|
||||||
* {@link #DEFAULT_MIME_TYPE} if it can't guess.
|
|
||||||
*
|
|
||||||
* @param filePath path of the file to guess its MIME type.
|
|
||||||
* @return MIME type guessed from file extension or {@link #DEFAULT_MIME_TYPE}.
|
|
||||||
*/
|
|
||||||
@NonNull
|
|
||||||
public static String guessMimeType(@NonNull String filePath) {
|
|
||||||
String mimeType = MimeUtil.getMimeFromFileName(filePath);
|
|
||||||
return mimeType == null ? DEFAULT_MIME_TYPE : mimeType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.webui
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.ActivityManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.ViewGroup.MarginLayoutParams
|
|
||||||
import android.webkit.WebResourceRequest
|
|
||||||
import android.webkit.WebResourceResponse
|
|
||||||
import android.webkit.WebView
|
|
||||||
import android.webkit.WebViewClient
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.core.view.updateLayoutParams
|
|
||||||
import androidx.webkit.WebViewAssetLoader
|
|
||||||
import com.topjohnwu.superuser.Shell
|
|
||||||
import com.sukisu.ultra.ui.util.createRootShell
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
|
||||||
class WebUIActivity : ComponentActivity() {
|
|
||||||
private lateinit var webviewInterface: WebViewInterface
|
|
||||||
|
|
||||||
private var rootShell: Shell? = null
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
|
|
||||||
// Enable edge to edge
|
|
||||||
enableEdgeToEdge()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
window.isNavigationBarContrastEnforced = false
|
|
||||||
}
|
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
val moduleId = intent.getStringExtra("id")!!
|
|
||||||
val name = intent.getStringExtra("name")!!
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
setTaskDescription(ActivityManager.TaskDescription("KernelSU - $name"))
|
|
||||||
} else {
|
|
||||||
val taskDescription = ActivityManager.TaskDescription.Builder().setLabel("KernelSU - $name").build()
|
|
||||||
setTaskDescription(taskDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
val prefs = getSharedPreferences("settings", MODE_PRIVATE)
|
|
||||||
WebView.setWebContentsDebuggingEnabled(prefs.getBoolean("enable_web_debugging", false))
|
|
||||||
|
|
||||||
val moduleDir = "/data/adb/modules/${moduleId}"
|
|
||||||
val webRoot = File("${moduleDir}/webroot")
|
|
||||||
val rootShell = createRootShell(true).also { this.rootShell = it }
|
|
||||||
val webViewAssetLoader = WebViewAssetLoader.Builder()
|
|
||||||
.setDomain("mui.kernelsu.org")
|
|
||||||
.addPathHandler(
|
|
||||||
"/",
|
|
||||||
SuFilePathHandler(this, webRoot, rootShell)
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val webViewClient = object : WebViewClient() {
|
|
||||||
override fun shouldInterceptRequest(
|
|
||||||
view: WebView,
|
|
||||||
request: WebResourceRequest
|
|
||||||
): WebResourceResponse? {
|
|
||||||
return webViewAssetLoader.shouldInterceptRequest(request.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val webView = WebView(this).apply {
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
|
|
||||||
val inset = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
|
||||||
view.updateLayoutParams<MarginLayoutParams> {
|
|
||||||
leftMargin = inset.left
|
|
||||||
rightMargin = inset.right
|
|
||||||
topMargin = inset.top
|
|
||||||
bottomMargin = inset.bottom
|
|
||||||
}
|
|
||||||
return@setOnApplyWindowInsetsListener insets
|
|
||||||
}
|
|
||||||
settings.javaScriptEnabled = true
|
|
||||||
settings.domStorageEnabled = true
|
|
||||||
settings.allowFileAccess = false
|
|
||||||
webviewInterface = WebViewInterface(this@WebUIActivity, this, moduleDir)
|
|
||||||
addJavascriptInterface(webviewInterface, "ksu")
|
|
||||||
setWebViewClient(webViewClient)
|
|
||||||
loadUrl("https://mui.kernelsu.org/index.html")
|
|
||||||
}
|
|
||||||
|
|
||||||
setContentView(webView)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
runCatching { rootShell?.close() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
package com.sukisu.ultra.ui.webui
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.text.TextUtils
|
|
||||||
import android.view.Window
|
|
||||||
import android.webkit.JavascriptInterface
|
|
||||||
import android.webkit.WebView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
|
||||||
import com.topjohnwu.superuser.CallbackList
|
|
||||||
import com.topjohnwu.superuser.ShellUtils
|
|
||||||
import com.topjohnwu.superuser.internal.UiThreadHandler
|
|
||||||
import com.sukisu.ultra.ui.util.createRootShell
|
|
||||||
import com.sukisu.ultra.ui.util.listModules
|
|
||||||
import com.sukisu.ultra.ui.util.withNewRootShell
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import com.sukisu.ultra.ui.util.controlKpmModule
|
|
||||||
import com.sukisu.ultra.ui.util.listKpmModules
|
|
||||||
import java.io.File
|
|
||||||
import java.util.concurrent.CompletableFuture
|
|
||||||
|
|
||||||
class WebViewInterface(
|
|
||||||
val context: Context,
|
|
||||||
private val webView: WebView,
|
|
||||||
private val modDir: String
|
|
||||||
) {
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun exec(cmd: String): String {
|
|
||||||
return withNewRootShell(true) { ShellUtils.fastCmd(this, cmd) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun exec(cmd: String, callbackFunc: String) {
|
|
||||||
exec(cmd, null, callbackFunc)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun processOptions(sb: StringBuilder, options: String?) {
|
|
||||||
val opts = if (options == null) JSONObject() else {
|
|
||||||
JSONObject(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
val cwd = opts.optString("cwd")
|
|
||||||
if (!TextUtils.isEmpty(cwd)) {
|
|
||||||
sb.append("cd ${cwd};")
|
|
||||||
}
|
|
||||||
|
|
||||||
opts.optJSONObject("env")?.let { env ->
|
|
||||||
env.keys().forEach { key ->
|
|
||||||
sb.append("export ${key}=${env.getString(key)};")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun exec(
|
|
||||||
cmd: String,
|
|
||||||
options: String?,
|
|
||||||
callbackFunc: String
|
|
||||||
) {
|
|
||||||
val finalCommand = StringBuilder()
|
|
||||||
processOptions(finalCommand, options)
|
|
||||||
finalCommand.append(cmd)
|
|
||||||
|
|
||||||
val result = withNewRootShell(true) {
|
|
||||||
newJob().add(finalCommand.toString()).to(ArrayList(), ArrayList()).exec()
|
|
||||||
}
|
|
||||||
val stdout = result.out.joinToString(separator = "\n")
|
|
||||||
val stderr = result.err.joinToString(separator = "\n")
|
|
||||||
|
|
||||||
val jsCode =
|
|
||||||
"javascript: (function() { try { ${callbackFunc}(${result.code}, ${
|
|
||||||
JSONObject.quote(
|
|
||||||
stdout
|
|
||||||
)
|
|
||||||
}, ${JSONObject.quote(stderr)}); } catch(e) { console.error(e); } })();"
|
|
||||||
webView.post {
|
|
||||||
webView.loadUrl(jsCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun spawn(command: String, args: String, options: String?, callbackFunc: String) {
|
|
||||||
val finalCommand = StringBuilder()
|
|
||||||
|
|
||||||
processOptions(finalCommand, options)
|
|
||||||
|
|
||||||
if (!TextUtils.isEmpty(args)) {
|
|
||||||
finalCommand.append(command).append(" ")
|
|
||||||
JSONArray(args).let { argsArray ->
|
|
||||||
for (i in 0 until argsArray.length()) {
|
|
||||||
finalCommand.append(argsArray.getString(i))
|
|
||||||
finalCommand.append(" ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
finalCommand.append(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
val shell = createRootShell(true)
|
|
||||||
|
|
||||||
val emitData = fun(name: String, data: String) {
|
|
||||||
val jsCode =
|
|
||||||
"javascript: (function() { try { ${callbackFunc}.${name}.emit('data', ${
|
|
||||||
JSONObject.quote(
|
|
||||||
data
|
|
||||||
)
|
|
||||||
}); } catch(e) { console.error('emitData', e); } })();"
|
|
||||||
webView.post {
|
|
||||||
webView.loadUrl(jsCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val stdout = object : CallbackList<String>(UiThreadHandler::runAndWait) {
|
|
||||||
override fun onAddElement(s: String) {
|
|
||||||
emitData("stdout", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val stderr = object : CallbackList<String>(UiThreadHandler::runAndWait) {
|
|
||||||
override fun onAddElement(s: String) {
|
|
||||||
emitData("stderr", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val future = shell.newJob().add(finalCommand.toString()).to(stdout, stderr).enqueue()
|
|
||||||
val completableFuture = CompletableFuture.supplyAsync {
|
|
||||||
future.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
completableFuture.thenAccept { result ->
|
|
||||||
val emitExitCode =
|
|
||||||
"javascript: (function() { try { ${callbackFunc}.emit('exit', ${result.code}); } catch(e) { console.error(`emitExit error: \${e}`); } })();"
|
|
||||||
webView.post {
|
|
||||||
webView.loadUrl(emitExitCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.code != 0) {
|
|
||||||
val emitErrCode =
|
|
||||||
"javascript: (function() { try { var err = new Error(); err.exitCode = ${result.code}; err.message = ${
|
|
||||||
JSONObject.quote(
|
|
||||||
result.err.joinToString(
|
|
||||||
"\n"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
};${callbackFunc}.emit('error', err); } catch(e) { console.error('emitErr', e); } })();"
|
|
||||||
webView.post {
|
|
||||||
webView.loadUrl(emitErrCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.whenComplete { _, _ ->
|
|
||||||
runCatching { shell.close() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun toast(msg: String) {
|
|
||||||
webView.post {
|
|
||||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun fullScreen(enable: Boolean) {
|
|
||||||
if (context is Activity) {
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
|
||||||
if (enable) {
|
|
||||||
hideSystemUI(context.window)
|
|
||||||
} else {
|
|
||||||
showSystemUI(context.window)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun moduleInfo(): String {
|
|
||||||
val moduleInfos = JSONArray(listModules())
|
|
||||||
var currentModuleInfo = JSONObject()
|
|
||||||
currentModuleInfo.put("moduleDir", modDir)
|
|
||||||
val moduleId = File(modDir).getName()
|
|
||||||
for (i in 0 until moduleInfos.length()) {
|
|
||||||
val currentInfo = moduleInfos.getJSONObject(i)
|
|
||||||
|
|
||||||
if (currentInfo.getString("id") != moduleId) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var keys = currentInfo.keys()
|
|
||||||
for (key in keys) {
|
|
||||||
currentModuleInfo.put(key, currentInfo.get(key))
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return currentModuleInfo.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================== KPM支持 =============================
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun listAllKpm() : String {
|
|
||||||
return listKpmModules()
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun controlKpm(name: String, args: String) : Int {
|
|
||||||
return controlKpmModule(name, args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hideSystemUI(window: Window) =
|
|
||||||
WindowInsetsControllerCompat(window, window.decorView).let { controller ->
|
|
||||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
|
||||||
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
|
||||||
}
|
|
||||||
|
|
||||||
fun showSystemUI(window: Window) =
|
|
||||||
WindowInsetsControllerCompat(window, window.decorView).show(WindowInsetsCompat.Type.systemBars())
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package com.sukisu.ultra.utils
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
object AssetsUtil {
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun exportFiles(context: Context, src: String, out: String) {
|
|
||||||
val fileNames = context.assets.list(src)
|
|
||||||
if (fileNames?.isNotEmpty() == true) {
|
|
||||||
val file = File(out)
|
|
||||||
file.mkdirs()
|
|
||||||
fileNames.forEach { fileName ->
|
|
||||||
exportFiles(context, "$src/$fileName", "$out/$fileName")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
context.assets.open(src).use { inputStream ->
|
|
||||||
FileOutputStream(File(out)).use { outputStream ->
|
|
||||||
inputStream.copyTo(outputStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package io.sukisu.ultra;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
import com.sukisu.ultra.ui.util.KsuCli;
|
|
||||||
|
|
||||||
public class UltraShellHelper {
|
|
||||||
public static String runCmd(String cmds) {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
for(String str : KsuCli.INSTANCE.getGLOBAL_MNT_SHELL()
|
|
||||||
.newJob()
|
|
||||||
.add(cmds)
|
|
||||||
.to(new ArrayList<>(), null)
|
|
||||||
.exec()
|
|
||||||
.getOut()) {
|
|
||||||
sb.append(str).append("\n");
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean isPathExists(String path) {
|
|
||||||
return runCmd("file " + path).contains("No such file or directory");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void CopyFileTo(String path, String target) {
|
|
||||||
runCmd("cp -f " + path + " " + target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user