Showing preview only (753K chars total). Download the full file or copy to clipboard to get everything.
Repository: bunny-mod/BunnyManager
Branch: main
Commit: 2a3b6a637b9e
Files: 183
Total size: 669.3 KB
Directory structure:
gitextract_m7wwd48o/
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ ├── build-debug.yml
│ └── build-release.yml
├── .gitignore
├── LICENSE
├── README.md
├── app/
│ ├── .gitignore
│ ├── build.gradle.kts
│ ├── libs/
│ │ ├── convert-lspatch.sh
│ │ └── lspatch.aar
│ ├── proguard-rules.pro
│ └── src/
│ └── main/
│ ├── AndroidManifest.xml
│ ├── java/
│ │ └── dev/
│ │ └── beefers/
│ │ └── vendetta/
│ │ └── manager/
│ │ ├── ManagerApplication.kt
│ │ ├── di/
│ │ │ ├── HttpModule.kt
│ │ │ ├── ManagerModule.kt
│ │ │ ├── RepositoryModule.kt
│ │ │ └── ViewModelModule.kt
│ │ ├── domain/
│ │ │ ├── manager/
│ │ │ │ ├── DownloadManager.kt
│ │ │ │ ├── InstallManager.kt
│ │ │ │ ├── PreferenceManager.kt
│ │ │ │ └── base/
│ │ │ │ └── BasePreferenceManager.kt
│ │ │ ├── receiver/
│ │ │ │ └── InstallReceiver.kt
│ │ │ └── repository/
│ │ │ └── RestRepository.kt
│ │ ├── installer/
│ │ │ ├── Installer.kt
│ │ │ ├── session/
│ │ │ │ ├── SessionInstaller.kt
│ │ │ │ └── SessionInstallerService.kt
│ │ │ ├── shizuku/
│ │ │ │ ├── ShizukuInstaller.kt
│ │ │ │ └── ShizukuPermissions.kt
│ │ │ ├── step/
│ │ │ │ ├── Step.kt
│ │ │ │ ├── StepGroup.kt
│ │ │ │ ├── StepRunner.kt
│ │ │ │ ├── StepStatus.kt
│ │ │ │ ├── download/
│ │ │ │ │ ├── DownloadBaseStep.kt
│ │ │ │ │ ├── DownloadLangStep.kt
│ │ │ │ │ ├── DownloadLibsStep.kt
│ │ │ │ │ ├── DownloadResourcesStep.kt
│ │ │ │ │ ├── DownloadVendettaStep.kt
│ │ │ │ │ └── base/
│ │ │ │ │ └── DownloadStep.kt
│ │ │ │ ├── installing/
│ │ │ │ │ └── InstallStep.kt
│ │ │ │ └── patching/
│ │ │ │ ├── AddVendettaStep.kt
│ │ │ │ ├── PatchManifestsStep.kt
│ │ │ │ ├── PresignApksStep.kt
│ │ │ │ └── ReplaceIconStep.kt
│ │ │ └── util/
│ │ │ ├── ArscUtil.kt
│ │ │ ├── AxmlUtil.kt
│ │ │ ├── LogEntry.kt
│ │ │ ├── Logger.kt
│ │ │ ├── ManifestPatcher.kt
│ │ │ ├── Patcher.kt
│ │ │ └── Signer.kt
│ │ ├── network/
│ │ │ ├── dto/
│ │ │ │ ├── Commit.kt
│ │ │ │ ├── Index.kt
│ │ │ │ ├── Release.kt
│ │ │ │ └── User.kt
│ │ │ ├── service/
│ │ │ │ ├── HttpService.kt
│ │ │ │ └── RestService.kt
│ │ │ └── utils/
│ │ │ ├── ApiResponse.kt
│ │ │ └── CommitsPagingSource.kt
│ │ ├── ui/
│ │ │ ├── activity/
│ │ │ │ └── MainActivity.kt
│ │ │ ├── components/
│ │ │ │ ├── Label.kt
│ │ │ │ ├── NavBarSpacer.kt
│ │ │ │ ├── RadioController.kt
│ │ │ │ ├── SegmentedButton.kt
│ │ │ │ ├── ThinDivider.kt
│ │ │ │ └── settings/
│ │ │ │ ├── SettingsButton.kt
│ │ │ │ ├── SettingsCategory.kt
│ │ │ │ ├── SettingsChoiceDialog.kt
│ │ │ │ ├── SettingsHeader.kt
│ │ │ │ ├── SettingsItem.kt
│ │ │ │ ├── SettingsItemChoice.kt
│ │ │ │ ├── SettingsSwitch.kt
│ │ │ │ └── SettingsTextField.kt
│ │ │ ├── screen/
│ │ │ │ ├── about/
│ │ │ │ │ └── AboutScreen.kt
│ │ │ │ ├── home/
│ │ │ │ │ └── HomeScreen.kt
│ │ │ │ ├── installer/
│ │ │ │ │ ├── InstallerScreen.kt
│ │ │ │ │ └── LogViewerScreen.kt
│ │ │ │ ├── libraries/
│ │ │ │ │ └── LibrariesScreen.kt
│ │ │ │ └── settings/
│ │ │ │ ├── AdvancedSettings.kt
│ │ │ │ ├── AppearanceSettings.kt
│ │ │ │ ├── CustomizationSettings.kt
│ │ │ │ ├── DeveloperSettings.kt
│ │ │ │ └── SettingsScreen.kt
│ │ │ ├── theme/
│ │ │ │ ├── Theme.kt
│ │ │ │ └── Type.kt
│ │ │ ├── viewmodel/
│ │ │ │ ├── home/
│ │ │ │ │ └── HomeViewModel.kt
│ │ │ │ ├── installer/
│ │ │ │ │ ├── InstallerViewModel.kt
│ │ │ │ │ └── LogViewerViewModel.kt
│ │ │ │ ├── libraries/
│ │ │ │ │ └── LibrariesViewModel.kt
│ │ │ │ └── settings/
│ │ │ │ └── AdvancedSettingsViewModel.kt
│ │ │ └── widgets/
│ │ │ ├── AppIcon.kt
│ │ │ ├── about/
│ │ │ │ ├── LinkItem.kt
│ │ │ │ ├── ListItem.kt
│ │ │ │ └── UserEntry.kt
│ │ │ ├── dialog/
│ │ │ │ ├── BackWarningDialog.kt
│ │ │ │ ├── DownloadFailedDialog.kt
│ │ │ │ ├── EndOfLifeDialog.kt
│ │ │ │ └── StoragePermissionsDialog.kt
│ │ │ ├── home/
│ │ │ │ ├── Commit.kt
│ │ │ │ └── CommitList.kt
│ │ │ ├── installer/
│ │ │ │ ├── LogLine.kt
│ │ │ │ ├── StepGroupCard.kt
│ │ │ │ ├── StepIcon.kt
│ │ │ │ └── StepRow.kt
│ │ │ ├── libraries/
│ │ │ │ ├── LibraryItem.kt
│ │ │ │ └── LicenseBottomSheet.kt
│ │ │ ├── settings/
│ │ │ │ ├── ThemePicker.kt
│ │ │ │ ├── ThemePickerOption.kt
│ │ │ │ └── ThemePreview.kt
│ │ │ └── updater/
│ │ │ └── UpdateDialog.kt
│ │ ├── updatechecker/
│ │ │ ├── reciever/
│ │ │ │ └── UpdateBroadcastReceiver.kt
│ │ │ └── worker/
│ │ │ └── UpdateWorker.kt
│ │ └── utils/
│ │ ├── Constants.kt
│ │ ├── Context.kt
│ │ ├── DimenUtils.kt
│ │ ├── LazyUtils.kt
│ │ ├── ModifierUtils.kt
│ │ ├── NavUtils.kt
│ │ ├── Utils.kt
│ │ └── VersionUtils.kt
│ └── res/
│ ├── drawable/
│ │ ├── bunny_logo.xml
│ │ ├── ic_discord.xml
│ │ ├── ic_discord_icon.xml
│ │ ├── ic_github.xml
│ │ ├── ic_update.xml
│ │ ├── ts_avatars.xml
│ │ ├── ts_bg.xml
│ │ ├── ts_content.xml
│ │ ├── ts_content_50.xml
│ │ ├── ts_icon.xml
│ │ ├── ts_outline.xml
│ │ ├── ts_primary.xml
│ │ ├── ts_status.xml
│ │ ├── ts_surface_l1.xml
│ │ └── ts_surface_l2.xml
│ ├── drawable-v26/
│ │ └── ic_launcher.xml
│ ├── values/
│ │ ├── colors.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ ├── values-af-rZA/
│ │ └── strings.xml
│ ├── values-ar-rSA/
│ │ └── strings.xml
│ ├── values-ca-rES/
│ │ └── strings.xml
│ ├── values-cs-rCZ/
│ │ └── strings.xml
│ ├── values-da-rDK/
│ │ └── strings.xml
│ ├── values-de-rDE/
│ │ └── strings.xml
│ ├── values-el-rGR/
│ │ └── strings.xml
│ ├── values-en-rUS/
│ │ └── strings.xml
│ ├── values-es-rES/
│ │ └── strings.xml
│ ├── values-fi-rFI/
│ │ └── strings.xml
│ ├── values-fil-rPH/
│ │ └── strings.xml
│ ├── values-fr-rFR/
│ │ └── strings.xml
│ ├── values-hu-rHU/
│ │ └── strings.xml
│ ├── values-in-rID/
│ │ └── strings.xml
│ ├── values-it-rIT/
│ │ └── strings.xml
│ ├── values-iw-rIL/
│ │ └── strings.xml
│ ├── values-ja-rJP/
│ │ └── strings.xml
│ ├── values-ko-rKR/
│ │ └── strings.xml
│ ├── values-nl-rNL/
│ │ └── strings.xml
│ ├── values-no-rNO/
│ │ └── strings.xml
│ ├── values-or-rIN/
│ │ └── strings.xml
│ ├── values-pl-rPL/
│ │ └── strings.xml
│ ├── values-pt-rBR/
│ │ └── strings.xml
│ ├── values-pt-rPT/
│ │ └── strings.xml
│ ├── values-ro-rRO/
│ │ └── strings.xml
│ ├── values-ru-rRU/
│ │ └── strings.xml
│ ├── values-sr-rSP/
│ │ └── strings.xml
│ ├── values-sv-rSE/
│ │ └── strings.xml
│ ├── values-tr-rTR/
│ │ └── strings.xml
│ ├── values-uk-rUA/
│ │ └── strings.xml
│ ├── values-vi-rVN/
│ │ └── strings.xml
│ ├── values-zh-rCN/
│ │ └── strings.xml
│ ├── values-zh-rTW/
│ │ └── strings.xml
│ └── xml/
│ ├── backup_rules.xml
│ ├── data_extraction_rules.xml
│ ├── locales_config.xml
│ └── provider_paths.xml
├── build.gradle.kts
├── gradle/
│ ├── libs.versions.toml
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
└── settings.gradle.kts
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
github: wingio
================================================
FILE: .github/workflows/build-debug.yml
================================================
name: Build debug APK
on:
push:
branches:
- '*'
paths-ignore:
- '**.md'
- '.idea/*'
- 'LICENSE'
pull_request:
branches:
- '*'
paths-ignore:
- '**.md'
- '.idea/*'
- 'LICENSE'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup JDK 17
uses: actions/setup-java@v2
with:
java-version: 17
distribution: 'temurin'
- name: Setup Android SDK
uses: android-actions/setup-android@v2.0.10
- name: Build APK
run: chmod +x ./gradlew && ./gradlew assembleDebug
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: manager-debug
path: app/build/outputs/apk/debug/app-debug.apk
================================================
FILE: .github/workflows/build-release.yml
================================================
name: Build Release
on:
workflow_dispatch:
inputs:
versionName:
required: true
description: This releases version name
default: "1.0.0"
versionCode:
required: true
description: This releases version code
default: "1000"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@master
- name: Setup JDK 17
uses: actions/setup-java@v2
with:
java-version: 17
distribution: 'temurin'
- name: Setup Android SDK
uses: android-actions/setup-android@v2.0.10
- name: Set Version
uses: chkfung/android-version-actions@v1.1
with:
gradlePath: app/build.gradle.kts
versionCode: ${{github.event.inputs.versionCode}}
versionName: ${{github.event.inputs.versionName}}
- name: Build Signed APK
run: |
echo "${{ secrets.keystore }}" | base64 -d > $GITHUB_WORKSPACE/signing-key.jks
chmod +x ./gradlew
./gradlew packageReleaseUniversalApk -Pandroid.injected.signing.store.file=$GITHUB_WORKSPACE/signing-key.jks -Pandroid.injected.signing.store.password=${{ secrets.keystore_password }} -Pandroid.injected.signing.key.alias=${{ secrets.key_alias }} -Pandroid.injected.signing.key.password=${{ secrets.key_password }}
- name: Release
run: |
mv app/build/outputs/apk_from_bundle/release/app-release-universal.apk ./Manager.apk
git config --local user.email "actions@github.com"
git config --local user.name "GitHub Actions"
tag="${{ github.event.inputs.versionCode }}"
git tag "$tag"
git push origin "$tag"
gh release create "$tag" \
--title "${{ github.event.inputs.versionName }}" \
--generate-notes \
./Manager.apk
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
================================================
FILE: .gitignore
================================================
# Built application files
*.apk
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
.kotlin/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
================================================
FILE: LICENSE
================================================
Open Software License ("OSL") v. 3.0
This Open Software License (the "License") applies to any original work of
authorship (the "Original Work") whose owner (the "Licensor") has placed the
following licensing notice adjacent to the copyright notice for the Original
Work:
Licensed under the Open Software License version 3.0
1) Grant of Copyright License. Licensor grants You a worldwide, royalty-free,
non-exclusive, sublicensable license, for the duration of the copyright, to do
the following:
a) to reproduce the Original Work in copies, either alone or as part of a
collective work;
b) to translate, adapt, alter, transform, modify, or arrange the Original
Work, thereby creating derivative works ("Derivative Works") based upon the
Original Work;
c) to distribute or communicate copies of the Original Work and Derivative
Works to the public, with the proviso that copies of Original Work or
Derivative Works that You distribute or communicate shall be licensed under
this Open Software License;
d) to perform the Original Work publicly; and
e) to display the Original Work publicly.
2) Grant of Patent License. Licensor grants You a worldwide, royalty-free,
non-exclusive, sublicensable license, under patent claims owned or controlled
by the Licensor that are embodied in the Original Work as furnished by the
Licensor, for the duration of the patents, to make, use, sell, offer for sale,
have made, and import the Original Work and Derivative Works.
3) Grant of Source Code License. The term "Source Code" means the preferred
form of the Original Work for making modifications to it and all available
documentation describing how to modify the Original Work. Licensor agrees to
provide a machine-readable copy of the Source Code of the Original Work along
with each copy of the Original Work that Licensor distributes. Licensor
reserves the right to satisfy this obligation by placing a machine-readable
copy of the Source Code in an information repository reasonably calculated to
permit inexpensive and convenient access by You for as long as Licensor
continues to distribute the Original Work.
4) Exclusions From License Grant. Neither the names of Licensor, nor the names
of any contributors to the Original Work, nor any of their trademarks or
service marks, may be used to endorse or promote products derived from this
Original Work without express prior permission of the Licensor. Except as
expressly stated herein, nothing in this License grants any license to
Licensor's trademarks, copyrights, patents, trade secrets or any other
intellectual property. No patent license is granted to make, use, sell, offer
for sale, have made, or import embodiments of any patent claims other than the
licensed claims defined in Section 2. No license is granted to the trademarks
of Licensor even if such marks are included in the Original Work. Nothing in
this License shall be interpreted to prohibit Licensor from licensing under
terms different from this License any Original Work that Licensor otherwise
would have a right to license.
5) External Deployment. The term "External Deployment" means the use,
distribution, or communication of the Original Work or Derivative Works in any
way such that the Original Work or Derivative Works may be used by anyone
other than You, whether those works are distributed or communicated to those
persons or made available as an application intended for use over a network.
As an express condition for the grants of license hereunder, You must treat
any External Deployment by You of the Original Work or a Derivative Work as a
distribution under section 1(c).
6) Attribution Rights. You must retain, in the Source Code of any Derivative
Works that You create, all copyright, patent, or trademark notices from the
Source Code of the Original Work, as well as any notices of licensing and any
descriptive text identified therein as an "Attribution Notice." You must cause
the Source Code for any Derivative Works that You create to carry a prominent
Attribution Notice reasonably calculated to inform recipients that You have
modified the Original Work.
7) Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that
the copyright in and to the Original Work and the patent rights granted herein
by Licensor are owned by the Licensor or are sublicensed to You under the
terms of this License with the permission of the contributor(s) of those
copyrights and patent rights. Except as expressly stated in the immediately
preceding sentence, the Original Work is provided under this License on an "AS
IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without
limitation, the warranties of non-infringement, merchantability or fitness for
a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK
IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this
License. No license to the Original Work is granted by this License except
under this disclaimer.
8) Limitation of Liability. Under no circumstances and under no legal theory,
whether in tort (including negligence), contract, or otherwise, shall the
Licensor be liable to anyone for any indirect, special, incidental, or
consequential damages of any character arising as a result of this License or
the use of the Original Work including, without limitation, damages for loss
of goodwill, work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses. This limitation of liability shall not
apply to the extent applicable law prohibits such limitation.
9) Acceptance and Termination. If, at any time, You expressly assented to this
License, that assent indicates your clear and irrevocable acceptance of this
License and all of its terms and conditions. If You distribute or communicate
copies of the Original Work or a Derivative Work, You must make a reasonable
effort under the circumstances to obtain the express assent of recipients to
the terms of this License. This License conditions your rights to undertake
the activities listed in Section 1, including your right to create Derivative
Works based upon the Original Work, and doing so without honoring these terms
and conditions is prohibited by copyright law and international treaty.
Nothing in this License is intended to affect copyright exceptions and
limitations (including "fair use" or "fair dealing"). This License shall
terminate immediately and You may no longer exercise any of the rights granted
to You by this License upon your failure to honor the conditions in Section
1(c).
10) Termination for Patent Action. This License shall terminate automatically
and You may no longer exercise any of the rights granted to You by this
License as of the date You commence an action, including a cross-claim or
counterclaim, against Licensor or any licensee alleging that the Original Work
infringes a patent. This termination provision shall not apply for an action
alleging patent infringement by combinations of the Original Work with other
software or hardware.
11) Jurisdiction, Venue and Governing Law. Any action or suit relating to this
License may be brought only in the courts of a jurisdiction wherein the
Licensor resides or in which Licensor conducts its primary business, and under
the laws of that jurisdiction excluding its conflict-of-law provisions. The
application of the United Nations Convention on Contracts for the
International Sale of Goods is expressly excluded. Any use of the Original
Work outside the scope of this License or after its termination shall be
subject to the requirements and penalties of copyright or patent law in the
appropriate jurisdiction. This section shall survive the termination of this
License.
12) Attorneys' Fees. In any action to enforce the terms of this License or
seeking damages relating thereto, the prevailing party shall be entitled to
recover its costs and expenses, including, without limitation, reasonable
attorneys' fees and costs incurred in connection with such action, including
any appeal of such action. This section shall survive the termination of this
License.
13) Miscellaneous. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent necessary
to make it enforceable.
14) Definition of "You" in This License. "You" throughout this License,
whether in upper or lower case, means an individual or a legal entity
exercising rights under, and complying with all of the terms of, this License.
For legal entities, "You" includes any entity that controls, is controlled by,
or is under common control with you. For purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the direction or
management of such entity, whether by contract or otherwise, or (ii) ownership
of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial
ownership of such entity.
15) Right to Use. You may use the Original Work in all ways not otherwise
restricted or conditioned by this License or by law, and Licensor promises not
to interfere with or be responsible for such uses by You.
16) Modification of This License. This License is Copyright © 2005 Lawrence
Rosen. Permission is granted to copy, distribute, or communicate this License
without modification. Nothing in this License permits You to modify this
License as applied to the Original Work or to Derivative Works. However, You
may modify the text of this License and copy, distribute or communicate your
modified version (the "Modified License") and apply it to other original works
of authorship subject to the following conditions: (i) You may not indicate in
any way that your Modified License is the "Open Software License" or "OSL" and
you may not use those names in the name of your Modified License; (ii) You
must replace the notice specified in the first paragraph above with the notice
"Licensed under <insert your license name here>" or with a notice of your own
that is not confusingly similar to the notice in this License; and (iii) You
may not claim that your original works are open source software unless your
Modified License has been approved by Open Source Initiative (OSI) and You
comply with its license review and certification process.
================================================
FILE: README.md
================================================
> [!IMPORTANT]
> ## Project Archival
>
> **This project is no longer actively maintained and this repository has been archived.**
>
> We want to thank all contributors and users for their support over the project's lifetime. While development has ceased, the code remains available for those who may find it useful.
<div align="center">
<img src="images/bunny_logo.png" alt="Bunny logo" width="200px" style="border-radius: 50%" />
# Bunny Manager
Easily install Bunny on Android
[](https://github.com/pyoncord/BunnyManager/releases/latest)
---
<br>

[](https://github.com/pyoncord/BunnyManager/stargazers)
[](https://discord.gg/XjYgWXHb9Q)
<br>

&color=blue)
&color=blue)

<br>
<img src="images/screenshot_home.jpg" width="200px">
</div>
Build
---
#### Prerequisites
- [Git](https://git-scm.com/downloads)
- [JDK 17](https://www.oracle.com/java/technologies/javase/jdk11-archive-downloads.html)
- [Android SDK](https://developer.android.com/studio)
#### Instructions
1. Clone the repo
- `git clone https://github.com/pyoncord/BunnyManager.git && cd BunnyManager`
2. Build the project
- Linux: `chmod +x ./gradlew && gradlew assembleDebug`
- Windows: `./gradlew assembleDebug`
3. Install on device
- [Enable USB debugging](https://developer.android.com/studio/debug/dev-options) and plug in your phone
- Run `adb install app/build/outputs/apk/debug/app-debug.apk`
## Contributing
This is an open-source project, you can do so without any programming.
Here are a few things you can do:
- [Test and report issues](https://github.com/pyoncord/BunnyManager/issues/new/choose)
<!-- - [Translate the app into your language](https://crowdin.com/project/vendetta-manager) -->
License
---
Bunny Manager is licensed under the Open Software License version 3.0
[](https://github.com/pyoncord/BunnyManager/blob/main/LICENSE)
================================================
FILE: app/.gitignore
================================================
/build
/release
================================================
FILE: app/build.gradle.kts
================================================
import java.io.ByteArrayOutputStream
plugins {
alias(libs.plugins.aboutlibraries)
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "dev.beefers.vendetta.manager"
compileSdk = 35
defaultConfig {
applicationId = "io.github.pyoncord.manager"
minSdk = 28
targetSdk = 35
versionCode = 1100
versionName = "1.1.0"
buildConfigField("String", "GIT_BRANCH", "\"${getCurrentBranch()}\"")
buildConfigField("String", "GIT_COMMIT", "\"${getLatestCommit()}\"")
buildConfigField("boolean", "GIT_LOCAL_COMMITS", "${hasLocalCommits()}")
buildConfigField("boolean", "GIT_LOCAL_CHANGES", "${hasLocalChanges()}")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
named("release") {
isCrunchPngs = true
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
freeCompilerArgs += listOf(
"-Xcontext-receivers",
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
layout.buildDirectory.get().asFile.resolve("report").absolutePath,
)
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.6"
}
androidComponents {
onVariants(selector().withBuildType("release")) {
it.packaging.resources.excludes.apply {
// Debug metadata
add("/**/*.version")
add("/kotlin-tooling-metadata.json")
// Kotlin debugging (https://github.com/Kotlin/kotlinx.coroutines/issues/2274)
add("/DebugProbesKt.bin")
}
}
}
packaging {
resources {
// Reflection symbol list (https://stackoverflow.com/a/41073782/13964629)
excludes += "/**/*.kotlin_builtins"
}
}
configurations {
all {
exclude(module = "listenablefuture")
exclude(module = "error_prone_annotations")
}
}
}
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.bundles.accompanist)
implementation(libs.bundles.androidx)
implementation(libs.bundles.coil)
implementation(libs.bundles.compose)
implementation(libs.bundles.koin)
implementation(libs.bundles.ktor)
implementation(libs.bundles.shizuku)
implementation(libs.bundles.voyager)
implementation(files("libs/lspatch.aar"))
implementation(libs.aboutlibraries.core)
implementation(libs.binaryResources) {
exclude(module = "checker-qual")
exclude(module = "jsr305")
exclude(module = "guava")
}
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.collections)
implementation(libs.zip.android) {
artifact {
type = "aar"
}
}
}
fun getCurrentBranch(): String? =
exec("git", "symbolic-ref", "--short", "HEAD")
fun getLatestCommit(): String? =
exec("git", "rev-parse", "--short", "HEAD")
fun hasLocalCommits(): Boolean {
val branch = getCurrentBranch() ?: return false
return exec("git", "log", "origin/$branch..HEAD")?.isNotBlank() ?: false
}
fun hasLocalChanges(): Boolean =
exec("git", "status", "-s")?.isNotEmpty() ?: false
fun exec(vararg command: String): String? {
return try {
val stdout = ByteArrayOutputStream()
val errout = ByteArrayOutputStream()
exec {
commandLine = command.toList()
standardOutput = stdout
errorOutput = errout
isIgnoreExitValue = true
}
if(errout.size() > 0)
throw Error(errout.toString(Charsets.UTF_8))
stdout.toString(Charsets.UTF_8).trim()
} catch (e: Throwable) {
e.printStackTrace()
null
}
}
================================================
FILE: app/libs/convert-lspatch.sh
================================================
# Download LSPatch jar and convert it to an aar
# Jars cannot be used as a dependency without breaking R8 optimization
# TODO: update to latest LSPatch
#LSPATCH_URL="https://github.com/LSPosed/LSPatch/releases/download/v0.6/jar-v0.6-398-release.jar"
LSPATCH_URL="https://github.com/vendetta-mod/VendettaManager/raw/5764b16a14c42d8449722f3656b2cb42019b82a8/app/libs/lspatch.jar"
LSPATCH_FILE_NAME="lspatch"
function cleanup {
rm -rf $LSPATCH_FILE_NAME.jar META-INF AndroidManifest.xml classes.jar R.txt
}
cleanup
# download lspatch
curl -sSL -o $LSPATCH_FILE_NAME.jar $LSPATCH_URL
# prepare aar contents
unzip -q $LSPATCH_FILE_NAME.jar "META-INF/**"
mv $LSPATCH_FILE_NAME.jar classes.jar
touch R.txt
cat >AndroidManifest.xml <<EOF
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="org.lsposed.lspatch">
<uses-sdk android:minSdkVersion="28" android:targetSdkVersion="34" />
</manifest>
EOF
# add everything to aar
rm -f $LSPATCH_FILE_NAME.aar
zip -rq $LSPATCH_FILE_NAME.aar R.txt AndroidManifest.xml META-INF classes.jar
cleanup
================================================
FILE: app/proguard-rules.pro
================================================
# Keep `Companion` object fields of serializable classes.
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
}
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
-if @kotlinx.serialization.Serializable class ** {
static **$* *;
}
-keepclassmembers class <2>$<3> {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep `INSTANCE.serializer()` of serializable objects.
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
-keepattributes InnerClasses # Needed for `getDeclaredClasses`.
-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept.
static <1>$$serializer INSTANCE;
}
# Fix missing apksig annotations and fields
-keep class com.android.apksig.internal.** { *; }
# Fix LSPatch breaking
# ref: https://github.com/LSPosed/LSPatch/blob/bbe8d93fb9230f7b04babaf1c4a11642110f55a6/manager/proguard-rules.pro#L12-L18
-keep class com.beust.jcommander.** { *; }
-keep class org.lsposed.lspatch.Patcher$Options { *; }
-keep class org.lsposed.lspatch.share.LSPConfig { *; }
-keep class org.lsposed.lspatch.share.PatchConfig { *; }
-keepclassmembers class org.lsposed.patch.LSPatch {
private <fields>;
}
# Uncomment this to preserve the line number information for
# debugging stack traces.
-keepattributes SourceFile,LineNumberTable
# Keep all names
-dontobfuscate
# Repackage classes into the top-level.
-repackageclasses
# Amount of optimization iterations, taken from an SO post
-optimizationpasses 5
# Broaden access modifiers to increase results during optimization
-allowaccessmodification
# Ignore missing classes (automatically generated by AGP)
-dontwarn com.google.auto.value.AutoValue$Builder
-dontwarn com.google.auto.value.AutoValue
-dontwarn org.bouncycastle.jsse.BCSSLParameters
-dontwarn org.bouncycastle.jsse.BCSSLSocket
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
-dontwarn org.conscrypt.Conscrypt$Version
-dontwarn org.conscrypt.Conscrypt
-dontwarn org.conscrypt.ConscryptHostnameVerifier
-dontwarn org.jetbrains.annotations.ApiStatus$Internal
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE
-dontwarn org.slf4j.impl.StaticLoggerBinder
# Can be removed once compose ui artifact is updated to 1.4.0
# ref: https://issuetracker.google.com/issues/265188224?pli=1
-keep,allowshrinking class * extends androidx.compose.ui.node.ModifierNodeElement {}
================================================
FILE: app/src/main/AndroidManifest.xml
================================================
<?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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission
android:name="android.permission.REQUEST_DELETE_PACKAGES"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.DELETE_PACKAGES"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:localeConfig="@xml/locales_config"
android:largeHeap="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.Manager"
android:name=".ManagerApplication"
tools:targetApi="tiramisu">
<activity
android:name=".ui.activity.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Manager.Splash"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="dev.beefers.vendetta.actions.INSTALL" />
<category android:name="android.intent.category.OPENABLE" />
</intent-filter>
</activity>
<service android:name=".installer.session.InstallService" />
<receiver android:name=".domain.receiver.InstallReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.PACKAGE_ADDED" />
<action android:name="android.intent.action.PACKAGE_REMOVED" />
<action android:name="android.intent.action.PACKAGE_CHANGED" />
<data android:scheme="package" />
</intent-filter>
</receiver>
<receiver android:name=".updatechecker.reciever.UpdateBroadcastReceiver" android:exported="false" />
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:multiprocess="false"
android:enabled="true"
android:exported="true"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths"/>
</provider>
</application>
</manifest>
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ManagerApplication.kt
================================================
package dev.beefers.vendetta.manager
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import dev.beefers.vendetta.manager.di.httpModule
import dev.beefers.vendetta.manager.di.managerModule
import dev.beefers.vendetta.manager.di.repositoryModule
import dev.beefers.vendetta.manager.di.viewModelModule
import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
import dev.beefers.vendetta.manager.domain.manager.UpdateCheckerDuration
import dev.beefers.vendetta.manager.updatechecker.worker.UpdateWorker
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
class ManagerApplication : Application() {
override fun onCreate() {
super.onCreate()
initNotificationChannels()
startKoin {
androidContext(this@ManagerApplication)
modules(
httpModule,
managerModule,
viewModelModule,
repositoryModule
)
}
val prefs: PreferenceManager = get()
if (prefs.updateDuration != UpdateCheckerDuration.DISABLED) {
val duration = prefs.updateDuration
WorkManager.getInstance(applicationContext).enqueueUniquePeriodicWork(
"dev.beefers.vendetta.manager.UPDATE_CHECK",
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<UpdateWorker>(duration.time, duration.unit).build()
)
}
}
private fun initNotificationChannels() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val updates = NotificationChannel(
"${BuildConfig.APPLICATION_ID}.notifications.UPDATE",
"Discord updates",
NotificationManager.IMPORTANCE_DEFAULT
)
nm.createNotificationChannel(updates)
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/di/HttpModule.kt
================================================
package dev.beefers.vendetta.manager.di
import dev.beefers.vendetta.manager.network.service.HttpService
import dev.beefers.vendetta.manager.network.service.RestService
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val httpModule = module {
fun provideJson() = Json {
ignoreUnknownKeys = true
isLenient = true
}
fun provideHttpClient(json: Json) = HttpClient(CIO) {
install(ContentNegotiation) {
json(json)
}
}
singleOf(::provideJson)
singleOf(::provideHttpClient)
singleOf(::HttpService)
singleOf(::RestService)
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/di/ManagerModule.kt
================================================
package dev.beefers.vendetta.manager.di
import dev.beefers.vendetta.manager.domain.manager.DownloadManager
import dev.beefers.vendetta.manager.domain.manager.InstallManager
import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val managerModule = module {
singleOf(::DownloadManager)
singleOf(::PreferenceManager)
singleOf(::InstallManager)
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/di/RepositoryModule.kt
================================================
package dev.beefers.vendetta.manager.di
import dev.beefers.vendetta.manager.domain.repository.RestRepository
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val repositoryModule = module {
singleOf(::RestRepository)
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/di/ViewModelModule.kt
================================================
package dev.beefers.vendetta.manager.di
import dev.beefers.vendetta.manager.ui.viewmodel.home.HomeViewModel
import dev.beefers.vendetta.manager.ui.viewmodel.installer.InstallerViewModel
import dev.beefers.vendetta.manager.ui.viewmodel.installer.LogViewerViewModel
import dev.beefers.vendetta.manager.ui.viewmodel.libraries.LibrariesViewModel
import dev.beefers.vendetta.manager.ui.viewmodel.settings.AdvancedSettingsViewModel
import org.koin.core.module.dsl.factoryOf
import org.koin.dsl.module
val viewModelModule = module {
factoryOf(::InstallerViewModel)
factoryOf(::AdvancedSettingsViewModel)
factoryOf(::HomeViewModel)
factoryOf(::LogViewerViewModel)
factoryOf(::LibrariesViewModel)
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/domain/manager/DownloadManager.kt
================================================
package dev.beefers.vendetta.manager.domain.manager
import android.app.DownloadManager
import android.content.Context
import android.database.Cursor
import android.net.Uri
import androidx.core.content.getSystemService
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import java.io.File
class DownloadManager(
private val context: Context,
private val prefs: PreferenceManager
) {
suspend fun downloadDiscordApk(version: String, out: File, onProgressUpdate: (Float?) -> Unit): DownloadResult =
download("${prefs.mirror.baseUrl}/tracker/download/$version/base", out, onProgressUpdate)
suspend fun downloadSplit(version: String, split: String, out: File, onProgressUpdate: (Float?) -> Unit): DownloadResult =
download("${prefs.mirror.baseUrl}/tracker/download/$version/$split", out, onProgressUpdate)
suspend fun downloadVendetta(out: File, onProgressUpdate: (Float?) -> Unit) =
download(
"https://github.com/pyoncord/BunnyXposed/releases/latest/download/app-release.apk",
out,
onProgressUpdate
)
suspend fun downloadUpdate(out: File) =
download(
"https://github.com/pyoncord/BunnyManager/releases/latest/download/Manager.apk",
out
) {
/* TODO: Update a progress bar in the update dialog */
}
/**
* Start a cancellable download with the system [DownloadManager].
* If the current [CoroutineScope] is cancelled, then the system download will be cancelled
* almost immediately.
* @param url Remote src url
* @param out Target path to download to
* @param onProgressUpdate Download progress update in a `[0,1]` range, and if null then the
* download is currently in a pending state. This is called every 100ms.
*/
suspend fun download(
url: String,
out: File,
onProgressUpdate: (Float?) -> Unit
): DownloadResult {
val downloadManager = context.getSystemService<DownloadManager>()
?: throw IllegalStateException("DownloadManager service is not available")
val downloadId = DownloadManager.Request(Uri.parse(url))
.setTitle("Bunny Manager")
.setDescription("Downloading ${out.name}...")
.setDestinationUri(Uri.fromFile(out))
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
.let(downloadManager::enqueue)
// Repeatedly request download state until it is finished
while (true) {
try {
// Hand over control to a suspend function to check for cancellation
delay(100)
} catch (_: CancellationException) {
// If the running CoroutineScope has been cancelled, then gracefully cancel download
downloadManager.remove(downloadId)
return DownloadResult.Cancelled(systemTriggered = false)
}
// Request download status
val cursor = DownloadManager.Query()
.setFilterById(downloadId)
.let(downloadManager::query)
// No results in cursor, download was cancelled
if (!cursor.moveToFirst()) {
cursor.close()
return DownloadResult.Cancelled(systemTriggered = true)
}
val statusColumn = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
val status = cursor.getInt(statusColumn)
cursor.use {
when (status) {
DownloadManager.STATUS_PENDING, DownloadManager.STATUS_PAUSED ->
onProgressUpdate(null)
DownloadManager.STATUS_RUNNING ->
onProgressUpdate(getDownloadProgress(cursor))
DownloadManager.STATUS_SUCCESSFUL ->
return DownloadResult.Success
DownloadManager.STATUS_FAILED -> {
val reasonColumn = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
val reason = cursor.getInt(reasonColumn)
return DownloadResult.Error(debugReason = convertErrorCode(reason))
}
}
}
}
}
private fun getDownloadProgress(queryCursor: Cursor): Float? {
val bytesColumn = queryCursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val bytes = queryCursor.getLong(bytesColumn)
val totalBytesColumn = queryCursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val totalBytes = queryCursor.getLong(totalBytesColumn)
if (totalBytes <= 0) return null
return bytes.toFloat() / totalBytes
}
private fun convertErrorCode(code: Int) = when (code) {
DownloadManager.ERROR_UNKNOWN -> "UNKNOWN"
DownloadManager.ERROR_FILE_ERROR -> "FILE_ERROR"
DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "UNHANDLED_HTTP_CODE"
DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP_DATA_ERROR"
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "TOO_MANY_REDIRECTS"
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "INSUFFICIENT_SPACE"
DownloadManager.ERROR_DEVICE_NOT_FOUND -> "DEVICE_NOT_FOUND"
DownloadManager.ERROR_CANNOT_RESUME -> "CANNOT_RESUME"
DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "FILE_ALREADY_EXISTS"
/* DownloadManager.ERROR_BLOCKED */ 1010 -> "NETWORK_BLOCKED"
else -> "UNKNOWN_CODE"
}
}
sealed interface DownloadResult {
data object Success : DownloadResult
data class Cancelled(val systemTriggered: Boolean) : DownloadResult
data class Error(val debugReason: String) : DownloadResult
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/domain/manager/InstallManager.kt
================================================
package dev.beefers.vendetta.manager.domain.manager
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import dev.beefers.vendetta.manager.installer.session.InstallService
class InstallManager(
private val context: Context,
private val prefs: PreferenceManager,
) {
var current by mutableStateOf<PackageInfo?>(null)
init {
getInstalled()
}
fun getInstalled() {
current = try {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
context.packageManager.getPackageInfo(
prefs.packageName.ifBlank { "io.github.pyoncord.app" },
PackageManager.PackageInfoFlags.of(
0L
)
)
}
else -> {
context.packageManager.getPackageInfo(
prefs.packageName.ifBlank { "io.github.pyoncord.app" },
0
)
}
}
} catch (e: PackageManager.NameNotFoundException) {
null
}
}
fun uninstall() {
current?.let {
val callbackIntent = Intent(context, InstallService::class.java).apply {
action = "vendetta.actions.ACTION_UNINSTALL"
}
@SuppressLint("UnspecifiedImmutableFlag")
val contentIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.getService(context, 0, callbackIntent, PendingIntent.FLAG_MUTABLE)
} else {
PendingIntent.getService(context, 0, callbackIntent, 0)
}
context.packageManager.packageInstaller.uninstall(
it.packageName,
contentIntent.intentSender
)
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/domain/manager/PreferenceManager.kt
================================================
package dev.beefers.vendetta.manager.domain.manager
import android.content.Context
import android.os.Build
import android.os.Environment
import androidx.annotation.StringRes
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.domain.manager.base.BasePreferenceManager
import dev.beefers.vendetta.manager.utils.DiscordVersion
import java.io.File
import java.util.concurrent.TimeUnit
class PreferenceManager(context: Context) :
BasePreferenceManager(context.getSharedPreferences("prefs", Context.MODE_PRIVATE)) {
val DEFAULT_MODULE_LOCATION =
(context.externalCacheDir ?: File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DOWNLOADS).resolve("BunnyManager").also { it.mkdirs() }).resolve("xposed.apk")
var packageName by stringPreference("package_name", "io.github.pyoncord.app")
var appName by stringPreference("app_name", "Bunny")
var discordVersion by stringPreference("discord_version", "")
var moduleVersion by stringPreference("module_version", "")
var patchIcon by booleanPreference("patch_icon", true)
var debuggable by booleanPreference("debuggable", false)
var mirror by enumPreference("mirror", Mirror.DEFAULT)
var monet by booleanPreference("monet", Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
var isDeveloper by booleanPreference("is_developer", false)
var autoClearCache by booleanPreference("auto_clear_cache", true)
var theme by enumPreference("theme", Theme.SYSTEM)
var channel by enumPreference("channel", DiscordVersion.Type.STABLE)
var updateDuration by enumPreference("update_duration", UpdateCheckerDuration.HOURLY)
var moduleLocation by filePreference("module_location", DEFAULT_MODULE_LOCATION)
var installMethod by enumPreference("install_method", InstallMethod.DEFAULT)
var logsAlternateBackground by booleanPreference("logs_alternate_bg", true)
var logsLineWrap by booleanPreference("logs_line_wrap", false)
var allowDowngrade by booleanPreference("allow_downgrade", false)
init {
// Will be removed next update
if(mirror == Mirror.VENDETTA_ROCKS) mirror = Mirror.VENDETTA_ROCKS_ALT
}
}
enum class Theme(@StringRes val labelRes: Int) {
SYSTEM(R.string.theme_system),
LIGHT(R.string.theme_light),
DARK(R.string.theme_dark)
}
enum class UpdateCheckerDuration(@StringRes val labelRes: Int, val time: Long, val unit: TimeUnit) {
DISABLED(R.string.duration_disabled, 0, TimeUnit.SECONDS),
QUARTERLY(R.string.duration_fifteen_min, 15, TimeUnit.MINUTES),
HALF_HOUR(R.string.duration_half_hour, 30, TimeUnit.MINUTES),
HOURLY(R.string.duration_hourly, 1, TimeUnit.HOURS),
BIHOURLY(R.string.duration_bihourly, 2, TimeUnit.HOURS),
TWICE_DAILY(R.string.duration_twice_daily, 12, TimeUnit.HOURS),
DAILY(R.string.duration_daily, 1, TimeUnit.DAYS),
WEEKLY(R.string.duration_weekly, 7, TimeUnit.DAYS)
}
enum class Mirror(val baseUrl: String) {
DEFAULT("https://tracker.vendetta.rocks"),
VENDETTA_ROCKS("https://proxy.vendetta.rocks"), // Temporarily added for compatibility
VENDETTA_ROCKS_ALT("https://proxy.vendetta.rocks"),
K6("https://vd.k6.tf"),
NEXPID("https://tracker.vd.nexpid.xyz")
}
enum class InstallMethod(@StringRes val labelRes: Int) {
DEFAULT(R.string.default_installer),
SHIZUKU(R.string.shizuku_installer)
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/domain/manager/base/BasePreferenceManager.kt
================================================
package dev.beefers.vendetta.manager.domain.manager.base
import android.content.SharedPreferences
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.core.content.edit
import java.io.File
import kotlin.reflect.KProperty
abstract class BasePreferenceManager(
private val prefs: SharedPreferences
) {
protected fun getString(key: String, defaultValue: String?) =
prefs.getString(key, defaultValue)!!
private fun getBoolean(key: String, defaultValue: Boolean) = prefs.getBoolean(key, defaultValue)
private fun getInt(key: String, defaultValue: Int) = prefs.getInt(key, defaultValue)
private fun getFloat(key: String, defaultValue: Float) = prefs.getFloat(key, defaultValue)
private fun getColor(key: String, defaultValue: Color): Color {
val c = prefs.getString(key, null)
return if (c == null) defaultValue else Color(c.toULong())
}
private fun getFile(key: String, defaultValue: File) =
File(getString(key, defaultValue.absolutePath))
protected inline fun <reified E : Enum<E>> getEnum(key: String, defaultValue: E) =
enumValueOf<E>(getString(key, defaultValue.name))
protected fun putString(key: String, value: String?) = prefs.edit { putString(key, value) }
private fun putBoolean(key: String, value: Boolean) = prefs.edit { putBoolean(key, value) }
private fun putInt(key: String, value: Int) = prefs.edit { putInt(key, value) }
private fun putFloat(key: String, value: Float) = prefs.edit { putFloat(key, value) }
private fun putColor(key: String, value: Color) =
prefs.edit { putString(key, value.value.toString()) }
private fun putFile(key: String, value: File) =
putString(key, value.absolutePath)
protected inline fun <reified E : Enum<E>> putEnum(key: String, value: E) =
putString(key, value.name)
protected class Preference<T>(
private val key: String,
defaultValue: T,
getter: (key: String, defaultValue: T) -> T,
private val setter: (key: String, newValue: T) -> Unit
) {
@Suppress("RedundantSetter")
var value by mutableStateOf(getter(key, defaultValue))
private set
operator fun getValue(thisRef: Any?, property: KProperty<*>) = value
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T) {
value = newValue
setter(key, newValue)
}
}
protected fun stringPreference(
key: String,
defaultValue: String = ""
) = Preference(
key = key,
defaultValue = defaultValue,
getter = ::getString,
setter = ::putString
)
protected fun booleanPreference(
key: String,
defaultValue: Boolean
) = Preference(
key = key,
defaultValue = defaultValue,
getter = ::getBoolean,
setter = ::putBoolean
)
protected fun intPreference(
key: String,
defaultValue: Int
) = Preference(
key = key,
defaultValue = defaultValue,
getter = ::getInt,
setter = ::putInt
)
protected fun floatPreference(
key: String,
defaultValue: Float
) = Preference(
key = key,
defaultValue = defaultValue,
getter = ::getFloat,
setter = ::putFloat
)
protected fun colorPreference(
key: String,
defaultValue: Color
) = Preference(
key = key,
defaultValue = defaultValue,
getter = ::getColor,
setter = ::putColor
)
protected fun filePreference(
key: String,
defaultValue: File
) = Preference(
key = key,
defaultValue = defaultValue,
getter = ::getFile,
setter = ::putFile
)
protected inline fun <reified E : Enum<E>> enumPreference(
key: String,
defaultValue: E
) = Preference(
key = key,
defaultValue = defaultValue,
getter = ::getEnum,
setter = ::putEnum
)
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/domain/receiver/InstallReceiver.kt
================================================
package dev.beefers.vendetta.manager.domain.receiver
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dev.beefers.vendetta.manager.domain.manager.InstallManager
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class InstallReceiver : BroadcastReceiver(), KoinComponent {
private val installManager: InstallManager by inject()
@SuppressLint("UnsafeProtectedBroadcastReceiver")
override fun onReceive(context: Context?, intent: Intent?) {
installManager.getInstalled()
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/domain/repository/RestRepository.kt
================================================
package dev.beefers.vendetta.manager.domain.repository
import dev.beefers.vendetta.manager.network.service.RestService
import dev.beefers.vendetta.manager.network.utils.transform
import dev.beefers.vendetta.manager.utils.DiscordVersion
class RestRepository(
private val service: RestService
) {
suspend fun getLatestRelease(repo: String) = service.getLatestRelease(repo)
suspend fun getLatestDiscordVersions() = service.getLatestDiscordVersions().transform {
mapOf(
DiscordVersion.Type.ALPHA to DiscordVersion.fromVersionCode(it.latest.alpha),
DiscordVersion.Type.BETA to DiscordVersion.fromVersionCode(it.latest.beta),
DiscordVersion.Type.STABLE to DiscordVersion.fromVersionCode(it.latest.stable)
)
}
suspend fun getCommits(repo: String, page: Int = 1) = service.getCommits(repo, page)
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/Installer.kt
================================================
package dev.beefers.vendetta.manager.installer
import java.io.File
interface Installer {
suspend fun installApks(silent: Boolean = false, vararg apks: File)
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/session/SessionInstaller.kt
================================================
package dev.beefers.vendetta.manager.installer.session
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller.SessionParams
import android.content.pm.PackageManager
import android.os.Build
import dev.beefers.vendetta.manager.installer.Installer
import java.io.File
internal class SessionInstaller(private val context: Context) : Installer {
private val packageManager: PackageManager = context.packageManager
override suspend fun installApks(silent: Boolean, vararg apks: File) {
val params = SessionParams(SessionParams.MODE_FULL_INSTALL).apply {
if (Build.VERSION.SDK_INT >= 31) {
setInstallScenario(PackageManager.INSTALL_SCENARIO_FAST)
if (silent) {
setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED)
}
}
}
val packageInstaller = packageManager.packageInstaller
val sessionId = packageInstaller.createSession(params)
val session = packageInstaller.openSession(sessionId)
apks.forEach { apk ->
session.openWrite(apk.name, 0, apk.length()).use {
it.write(apk.readBytes())
session.fsync(it)
}
}
val callbackIntent = Intent(context, InstallService::class.java).apply {
action = "vendetta.actions.ACTION_INSTALL"
}
@SuppressLint("UnspecifiedImmutableFlag")
val contentIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.getService(context, 0, callbackIntent, PendingIntent.FLAG_MUTABLE)
} else {
PendingIntent.getService(context, 0, callbackIntent, 0)
}
session.commit(contentIntent.intentSender)
session.close()
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/session/SessionInstallerService.kt
================================================
package dev.beefers.vendetta.manager.installer.session
import android.app.Service
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.IBinder
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.ui.activity.MainActivity
import dev.beefers.vendetta.manager.utils.showToast
class InstallService : Service() {
private val messages = mapOf(
PackageInstaller.STATUS_FAILURE to R.string.install_fail_generic,
PackageInstaller.STATUS_FAILURE_BLOCKED to R.string.install_fail_blocked,
PackageInstaller.STATUS_FAILURE_INVALID to R.string.install_fail_invalid,
PackageInstaller.STATUS_FAILURE_CONFLICT to R.string.install_fail_conflict,
PackageInstaller.STATUS_FAILURE_STORAGE to R.string.install_fail_storage,
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE to R.string.install_fail_incompatible,
8 /* STATUS_FAILURE_TIMEOUT (Added in Android 14) */ to R.string.install_fail_timeout
)
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
val isInstall = intent.action == "vendetta.actions.ACTION_INSTALL"
when (val statusCode = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
@Suppress("DEPRECATION") // No.
val confirmationIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)!!
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(confirmationIntent)
}
PackageInstaller.STATUS_SUCCESS -> if (isInstall) showToast(R.string.installer_success)
PackageInstaller.STATUS_FAILURE_ABORTED -> if (isInstall) showToast(R.string.installer_aborted)
else -> {
if (isInstall) {
messages[statusCode]?.let(::showToast)
// Send error messages back to the activity for debugging (to be received by InstallerScreen)
startActivity(
Intent("vendetta.actions.ACTION_INSTALL_FINISHED").apply {
setClass(this@InstallService, MainActivity::class.java)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra("vendetta.extras.EXTRA_MESSAGE", intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
}
)
}
}
}
stopSelf()
return START_NOT_STICKY
}
override fun onBind(intent: Intent): IBinder? = null
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/shizuku/ShizukuInstaller.kt
================================================
package dev.beefers.vendetta.manager.installer.shizuku
import android.content.Context
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.domain.manager.InstallManager
import dev.beefers.vendetta.manager.installer.Installer
import dev.beefers.vendetta.manager.utils.showToast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import rikka.shizuku.Shizuku
import java.io.File
class ShizukuInstaller(private val context: Context) : Installer, KoinComponent {
val installManager: InstallManager by inject()
override suspend fun installApks(silent: Boolean, vararg apks: File) {
if (!ShizukuPermissions.waitShizukuPermissions()) {
withContext(Dispatchers.Main) {
context.showToast(R.string.msg_shizuku_denied, short = false)
}
throw Error("Failed to install due to missing Shizuku permissions")
}
val tempDir = File("/data/local/tmp")
val movedApks = mutableListOf<File>()
// Copy each split to tmp
apks.forEach {
val moveCommand = "cp ${it.absolutePath} ${tempDir.absolutePath}"
val moveResult = executeShellCommand(moveCommand)
if(moveResult.isBlank())
movedApks.add(File(tempDir.absolutePath, it.name))
else
throw RuntimeException("Failed to move ${it.absolutePath} to temp dir")
}
val installCommand = "pm install ${movedApks.joinToString(" ") { it.absolutePath }}"
executeShellCommand(installCommand)
installManager.getInstalled()
movedApks.forEach {
it.delete()
}
}
private fun executeShellCommand(command: String): String {
@Suppress("DEPRECATION")
val process = Shizuku.newProcess(arrayOf("sh", "-c", command), null, null)
val errorStr = process.errorStream.bufferedReader().use { it.readText().trim() }
if(errorStr.isNotBlank()) throw RuntimeException("Failed to execute $command:\n\n$errorStr")
return process.inputStream.bufferedReader().use { it.readText().trim() }
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/shizuku/ShizukuPermissions.kt
================================================
package dev.beefers.vendetta.manager.installer.shizuku
import android.content.pm.PackageManager
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import rikka.shizuku.Shizuku
import rikka.shizuku.Shizuku.OnRequestPermissionResultListener
@OptIn(DelicateCoroutinesApi::class)
object ShizukuPermissions {
private const val REQUEST_CODE = 1
private val _permissionsGranted = MutableSharedFlow<Boolean>(replay = 0)
private lateinit var permissionResultListener: OnRequestPermissionResultListener
fun requestShizukuPermissions() {
if (!Shizuku.pingBinder()) {
GlobalScope.launch { _permissionsGranted.emit(false) }
return
}
if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
GlobalScope.launch { _permissionsGranted.emit(true) }
return
}
Shizuku.addRequestPermissionResultListener(permissionResultListener)
Shizuku.requestPermission(REQUEST_CODE)
}
suspend fun waitShizukuPermissions(): Boolean {
requestShizukuPermissions()
return _permissionsGranted.first()
}
init {
permissionResultListener = OnRequestPermissionResultListener { requestCode, grantResult ->
if (requestCode != REQUEST_CODE) return@OnRequestPermissionResultListener
Shizuku.removeRequestPermissionResultListener(permissionResultListener)
GlobalScope.launch {
_permissionsGranted.emit(grantResult == PackageManager.PERMISSION_GRANTED)
}
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/step/Step.kt
================================================
package dev.beefers.vendetta.manager.installer.step
import androidx.annotation.StringRes
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import org.koin.core.component.KoinComponent
import kotlin.time.measureTimedValue
/**
* A distinct step to be ran while patching
*/
@Stable
abstract class Step: KoinComponent {
/**
* Group this step belongs to
*/
abstract val group: StepGroup
/**
* Label used in the installer ui
*/
@get:StringRes
abstract val nameRes: Int
/**
* Current status for this step
*/
var status by mutableStateOf(StepStatus.QUEUED)
protected set
/**
* How much progress this step has made, use null if unknown
*/
var progress by mutableStateOf<Float?>(null)
protected set
/**
* How long this step took to run, in milliseconds
*/
var durationMs by mutableIntStateOf(0)
private set
/**
* Runs this step
*
* @param runner The host runner, used to share information between steps
*/
protected abstract suspend fun run(runner: StepRunner)
/**
* Safely runs this step, catching any errors and timing how long it runs.
*
* @param runner The host runner, used to share information between steps
*/
suspend fun runCatching(runner: StepRunner): Throwable? {
if (status != StepStatus.QUEUED)
throw IllegalStateException("Cannot execute a step that has already started")
status = StepStatus.ONGOING
val (error, time) = measureTimedValue {
try {
run(runner)
status = StepStatus.SUCCESSFUL
null
} catch (t: Throwable) {
status = StepStatus.UNSUCCESSFUL
t
}
}
durationMs = time.inWholeMilliseconds.toInt()
return error
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepGroup.kt
================================================
package dev.beefers.vendetta.manager.installer.step
import androidx.annotation.StringRes
import dev.beefers.vendetta.manager.R
/**
* Represents a group of [Step]s
*/
enum class StepGroup(@StringRes val nameRes: Int) {
/**
* All steps deal with downloading files remotely
*/
DL(R.string.group_download),
/**
* Steps that modify the APKs
*/
PATCHING(R.string.group_patch),
/**
* Only contains the [install step][dev.beefers.vendetta.manager.installer.step.installing.InstallStep]
*/
INSTALLING(R.string.group_installing)
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepRunner.kt
================================================
package dev.beefers.vendetta.manager.installer.step
import android.content.Context
import android.os.Build
import android.os.Environment
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import dev.beefers.vendetta.manager.BuildConfig
import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
import dev.beefers.vendetta.manager.installer.step.download.DownloadBaseStep
import dev.beefers.vendetta.manager.installer.step.download.DownloadLangStep
import dev.beefers.vendetta.manager.installer.step.download.DownloadLibsStep
import dev.beefers.vendetta.manager.installer.step.download.DownloadResourcesStep
import dev.beefers.vendetta.manager.installer.step.download.DownloadVendettaStep
import dev.beefers.vendetta.manager.installer.step.installing.InstallStep
import dev.beefers.vendetta.manager.installer.step.patching.AddVendettaStep
import dev.beefers.vendetta.manager.installer.step.patching.PatchManifestsStep
import dev.beefers.vendetta.manager.installer.step.patching.PresignApksStep
import dev.beefers.vendetta.manager.installer.step.patching.ReplaceIconStep
import dev.beefers.vendetta.manager.installer.util.LogEntry
import dev.beefers.vendetta.manager.installer.util.Logger
import dev.beefers.vendetta.manager.utils.DiscordVersion
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
/**
* Runs all installation steps in order
*
* Credit to rushii (github.com/rushiiMachine)
*
* @param discordVersion Version of Discord to inject Vendetta into
*/
@Stable
class StepRunner(
private val discordVersion: DiscordVersion
): KoinComponent {
private val preferenceManager: PreferenceManager by inject()
private val context: Context by inject()
private val debugInfo = """
Bunny Manager v${BuildConfig.VERSION_NAME}
Built from commit ${BuildConfig.GIT_COMMIT} on ${BuildConfig.GIT_BRANCH} ${if (BuildConfig.GIT_LOCAL_CHANGES || BuildConfig.GIT_LOCAL_COMMITS) "(Changes Present)" else ""}
Running Android ${Build.VERSION.RELEASE}, API level ${Build.VERSION.SDK_INT}
Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString()}
Device: ${Build.MANUFACTURER} - ${Build.MODEL} (${Build.DEVICE})
${if(Build.VERSION.SDK_INT > Build.VERSION_CODES.S) "SOC: ${Build.SOC_MANUFACTURER} ${Build.SOC_MODEL}\n" else "\n\n"}
Adding Bunny to Discord v$discordVersion
""".trimIndent()
/**
* Logger associated with this runner
*/
val logger = Logger("StepRunner").also { logger ->
debugInfo.split("\n").forEach {
logger.logs += LogEntry(it, LogEntry.Level.INFO) // Add debug information to logs but don't print to logcat
}
}
/**
* Root directory for all downloaded files
*/
private val cacheDir =
context.externalCacheDir
?: File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DOWNLOADS)
.resolve("BunnyManager")
.also { it.mkdirs() }
/**
* Where version specific downloads are persisted
*/
private val discordCacheDir = cacheDir.resolve(discordVersion.toVersionCode())
/**
* Working directory where apks are directly modified (i.e. replacing the app icon)
*/
private val patchedDir = discordCacheDir.resolve("patched").also { it.deleteRecursively() }
/**
* Where apks are moved to once signed
*/
private val signedDir = discordCacheDir.resolve("signed").also { it.deleteRecursively() }
/**
* Output directory for LSPatch
*/
private val lspatchedDir = patchedDir.resolve("lspatched").also { it.deleteRecursively() }
var currentStep by mutableStateOf<Step?>(null)
private set
/**
* Whether or not the patching/installation process has completed.
* Note that this does not mean all steps were finished successfully
*/
var completed by mutableStateOf(false)
private set
/**
* Whether or not a download step failed, this is only for errors related to network conditions and not cancellations
*/
var downloadErrored by mutableStateOf(false)
/**
* List of steps to go through for this install
*
* ORDER MATTERS
*/
val steps: ImmutableList<Step> = buildList {
// Downloading
add(DownloadBaseStep(discordCacheDir, patchedDir, discordVersion.toVersionCode()))
add(DownloadLibsStep(discordCacheDir, patchedDir, discordVersion.toVersionCode()))
add(DownloadLangStep(discordCacheDir, patchedDir, discordVersion.toVersionCode()))
add(DownloadResourcesStep(discordCacheDir, patchedDir, discordVersion.toVersionCode()))
add(DownloadVendettaStep(patchedDir))
// Patching
if (preferenceManager.patchIcon) add(ReplaceIconStep())
add(PatchManifestsStep())
add(PresignApksStep(signedDir))
add(AddVendettaStep(signedDir, lspatchedDir))
// Installing
add(InstallStep(lspatchedDir))
}.toImmutableList()
/**
* Get a step that has already been successfully executed.
* This is used to retrieve previously executed dependency steps from a later step.
*/
inline fun <reified T : Step> getCompletedStep(): T {
val step = steps.asSequence()
.filterIsInstance<T>()
.filter { it.status == StepStatus.SUCCESSFUL }
.firstOrNull()
if (step == null) {
throw IllegalArgumentException("No completed step ${T::class.simpleName} exists in container")
}
return step
}
/**
* Clears all cached files
*/
fun clearCache() {
cacheDir.deleteRecursively()
}
/**
* Run all the [steps] in order
*/
suspend fun runAll(): Throwable? {
for (step in steps) {
if (completed) return null // Failsafe in case runner is incorrectly marked as not completed too early
currentStep = step
val error = step.runCatching(this)
if (error != null) {
logger.e("Failed on ${step::class.simpleName}", error)
completed = true
return error
}
// Add delay for human psychology and
// better group visibility in UI (the active group can change way too fast)
if (!preferenceManager.isDeveloper && step.durationMs < 1000) {
delay(1000L - step.durationMs)
}
}
completed = true
return null
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepStatus.kt
================================================
package dev.beefers.vendetta.manager.installer.step
enum class StepStatus {
/**
* Currently in progress
*/
ONGOING,
/**
* Completed with no errors
*/
SUCCESSFUL,
/**
* Completed with an error
*/
UNSUCCESSFUL,
/**
* Has not yet been ran
*/
QUEUED
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadBaseStep.kt
================================================
package dev.beefers.vendetta.manager.installer.step.download
import androidx.compose.runtime.Stable
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.installer.step.download.base.DownloadStep
import java.io.File
/**
* Downloads the base Discord APK
*/
@Stable
class DownloadBaseStep(
dir: File,
workingDir: File,
version: String
): DownloadStep() {
override val nameRes = R.string.step_dl_base
override val url: String = "$baseUrl/tracker/download/$version/base"
override val destination = dir.resolve("base-$version.apk")
override val workingCopy = workingDir.resolve("base-$version.apk")
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadLangStep.kt
================================================
package dev.beefers.vendetta.manager.installer.step.download
import androidx.compose.runtime.Stable
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.installer.step.download.base.DownloadStep
import java.io.File
/**
* Downloads the languages split, will always be English because Discord doesn't store their strings in this split
*/
@Stable
class DownloadLangStep(
dir: File,
workingDir: File,
version: String
): DownloadStep() {
override val nameRes = R.string.step_dl_lang
override val url: String = "$baseUrl/tracker/download/$version/config.en"
override val destination = dir.resolve("config.en-$version.apk")
override val workingCopy = workingDir.resolve("config.en-$version.apk")
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadLibsStep.kt
================================================
package dev.beefers.vendetta.manager.installer.step.download
import android.os.Build
import androidx.compose.runtime.Stable
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.installer.step.download.base.DownloadStep
import java.io.File
/**
* Downloads the split containing the native libraries for the current devices architecture
*/
@Stable
class DownloadLibsStep(
dir: File,
workingDir: File,
version: String
): DownloadStep() {
/**
* Supported CPU architecture for this device, used to download the correct library split
*/
private val arch = Build.SUPPORTED_ABIS.first().replace("-v", "_v")
override val nameRes = R.string.step_dl_lib
override val url: String = "$baseUrl/tracker/download/$version/config.$arch"
override val destination = dir.resolve("config.$arch-$version.apk")
override val workingCopy = workingDir.resolve("config.$arch-$version.apk")
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadResourcesStep.kt
================================================
package dev.beefers.vendetta.manager.installer.step.download
import androidx.compose.runtime.Stable
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.installer.step.download.base.DownloadStep
import java.io.File
/**
* Downloads the split containing all images, fonts, and other assets
*/
@Stable
class DownloadResourcesStep(
dir: File,
workingDir: File,
version: String
): DownloadStep() {
override val nameRes = R.string.step_dl_res
override val url: String = "$baseUrl/tracker/download/$version/config.xxhdpi"
override val destination = dir.resolve("config.xxhdpi-$version.apk")
override val workingCopy = workingDir.resolve("config.xxhdpi-$version.apk")
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadVendettaStep.kt
================================================
package dev.beefers.vendetta.manager.installer.step.download
import androidx.compose.runtime.Stable
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.installer.step.download.base.DownloadStep
import java.io.File
/**
* Downloads the Vendetta XPosed module
*
* https://github.com/pyoncord/BunnyXposed
*/
@Stable
class DownloadVendettaStep(
workingDir: File
): DownloadStep() {
override val nameRes = R.string.step_dl_vd
override val url: String = "https://github.com/pyoncord/BunnyXposed/releases/latest/download/app-release.apk"
override val destination = preferenceManager.moduleLocation
override val workingCopy = workingDir.resolve("xposed.apk")
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/base/DownloadStep.kt
================================================
package dev.beefers.vendetta.manager.installer.step.download.base
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.domain.manager.DownloadManager
import dev.beefers.vendetta.manager.domain.manager.DownloadResult
import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
import dev.beefers.vendetta.manager.installer.step.Step
import dev.beefers.vendetta.manager.installer.step.StepGroup
import dev.beefers.vendetta.manager.installer.step.StepRunner
import dev.beefers.vendetta.manager.installer.step.StepStatus
import dev.beefers.vendetta.manager.utils.mainThread
import dev.beefers.vendetta.manager.utils.showToast
import kotlinx.coroutines.CancellationException
import org.koin.core.component.inject
import java.io.File
import kotlin.math.roundToInt
/**
* Specialized step used to download a file
*
* Files are downloaded to [destination] then copied to [workingCopy] for safe patching
*/
@Stable
abstract class DownloadStep: Step() {
protected val preferenceManager: PreferenceManager by inject()
protected val baseUrl = preferenceManager.mirror.baseUrl
private val downloadManager: DownloadManager by inject()
private val context: Context by inject()
/**
* Url of the desired file to download
*/
abstract val url: String
/**
* Where to download the file to
*/
abstract val destination: File
/**
* Where the downloaded file should be copied to so that it can be used for patching
*/
abstract val workingCopy: File
override val group: StepGroup = StepGroup.DL
var cached by mutableStateOf(false)
private set
/**
* Verifies that a file was properly downloaded
*/
open suspend fun verify() {
if (!destination.exists())
error("Downloaded file is missing: ${destination.absolutePath}")
if (destination.length() <= 0)
error("Downloaded file is empty: ${destination.absolutePath}")
}
override suspend fun run(runner: StepRunner) {
val fileName = destination.name
runner.logger.i("Checking if $fileName is cached")
if (destination.exists()) {
runner.logger.i("Checking if $fileName isn't empty")
if (destination.length() > 0) {
runner.logger.i("$fileName is cached")
cached = true
runner.logger.i("Moving $fileName to working directory")
destination.copyTo(workingCopy, true)
status = StepStatus.SUCCESSFUL
return
}
runner.logger.i("Deleting empty file: $fileName")
destination.delete()
}
runner.logger.i("$fileName was not properly cached, downloading now")
var lastProgress: Float? = null
val result = downloadManager.download(url, destination) { newProgress ->
progress = newProgress
if (newProgress != lastProgress && newProgress != null) {
lastProgress = newProgress
runner.logger.d("$fileName download progress: ${(lastProgress!! * 100f).roundToInt()}%")
}
}
when (result) {
is DownloadResult.Success -> {
try {
runner.logger.i("Verifying downloaded file")
verify()
runner.logger.i("$fileName downloaded successfully")
} catch (t: Throwable) {
mainThread {
context.showToast(R.string.msg_download_verify_failed)
}
throw t
}
runner.logger.i("Moving $fileName to working directory")
destination.copyTo(workingCopy, true)
}
is DownloadResult.Error -> {
mainThread {
context.showToast(R.string.msg_download_failed)
runner.downloadErrored = true
}
throw Error("Failed to download: ${result.debugReason}")
}
is DownloadResult.Cancelled -> {
status = StepStatus.UNSUCCESSFUL
throw CancellationException("$fileName download cancelled")
}
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/step/installing/InstallStep.kt
================================================
package dev.beefers.vendetta.manager.installer.step.installing
import android.content.Context
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.domain.manager.InstallMethod
import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
import dev.beefers.vendetta.manager.installer.Installer
import dev.beefers.vendetta.manager.installer.session.SessionInstaller
import dev.beefers.vendetta.manager.installer.shizuku.ShizukuInstaller
import dev.beefers.vendetta.manager.installer.step.Step
import dev.beefers.vendetta.manager.installer.step.StepGroup
import dev.beefers.vendetta.manager.installer.step.StepRunner
import dev.beefers.vendetta.manager.utils.isMiui
import org.koin.core.component.inject
import java.io.File
/**
* Installs all the modified splits with the users desired [Installer]
*
* @see SessionInstaller
* @see ShizukuInstaller
*
* @param lspatchedDir Where all the patched APKs are
*/
class InstallStep(
private val lspatchedDir: File
): Step() {
private val preferences: PreferenceManager by inject()
private val context: Context by inject()
override val group = StepGroup.INSTALLING
override val nameRes = R.string.step_installing
override suspend fun run(runner: StepRunner) {
runner.logger.i("Installing apks")
val files = lspatchedDir.listFiles()
?.takeIf { it.isNotEmpty() }
?: throw Error("Missing APKs from LSPatch step; failure likely")
val installer: Installer = when (preferences.installMethod) {
InstallMethod.DEFAULT -> SessionInstaller(context)
InstallMethod.SHIZUKU -> ShizukuInstaller(context)
}
installer.installApks(silent = !isMiui, *files)
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/AddVendettaStep.kt
================================================
package dev.beefers.vendetta.manager.installer.step.patching
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.installer.step.Step
import dev.beefers.vendetta.manager.installer.step.StepGroup
import dev.beefers.vendetta.manager.installer.step.StepRunner
import dev.beefers.vendetta.manager.installer.step.download.DownloadVendettaStep
import dev.beefers.vendetta.manager.installer.util.Patcher
import java.io.File
/**
* Uses LSPatch to inject the Vendetta XPosed module into Discord
*
* @param signedDir The signed apks to patch
* @param lspatchedDir Output directory for LSPatch
*/
class AddVendettaStep(
private val signedDir: File,
private val lspatchedDir: File
) : Step() {
override val group = StepGroup.PATCHING
override val nameRes = R.string.step_add_vd
override suspend fun run(runner: StepRunner) {
val vendetta = runner.getCompletedStep<DownloadVendettaStep>().workingCopy
runner.logger.i("Adding BunnyXposed module with LSPatch")
val files = signedDir.listFiles()
?.takeIf { it.isNotEmpty() }
?: throw Error("Missing APKs from signing step")
Patcher.patch(
runner.logger,
outputDir = lspatchedDir,
apkPaths = files.map { it.absolutePath },
embeddedModules = listOf(vendetta.absolutePath)
)
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/PatchManifestsStep.kt
================================================
package dev.beefers.vendetta.manager.installer.step.patching
import com.github.diamondminer88.zip.ZipReader
import com.github.diamondminer88.zip.ZipWriter
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
import dev.beefers.vendetta.manager.installer.step.Step
import dev.beefers.vendetta.manager.installer.step.StepGroup
import dev.beefers.vendetta.manager.installer.step.StepRunner
import dev.beefers.vendetta.manager.installer.step.download.DownloadBaseStep
import dev.beefers.vendetta.manager.installer.step.download.DownloadLangStep
import dev.beefers.vendetta.manager.installer.step.download.DownloadLibsStep
import dev.beefers.vendetta.manager.installer.step.download.DownloadResourcesStep
import dev.beefers.vendetta.manager.installer.util.ManifestPatcher
import org.koin.core.component.inject
/**
* Modifies each APKs manifest in order to change the package and app name as well as whether or not its debuggable
*/
class PatchManifestsStep : Step() {
private val preferences: PreferenceManager by inject()
override val group = StepGroup.PATCHING
override val nameRes = R.string.step_patch_manifests
override suspend fun run(runner: StepRunner) {
val baseApk = runner.getCompletedStep<DownloadBaseStep>().workingCopy
val libsApk = runner.getCompletedStep<DownloadLibsStep>().workingCopy
val langApk = runner.getCompletedStep<DownloadLangStep>().workingCopy
val resApk = runner.getCompletedStep<DownloadResourcesStep>().workingCopy
arrayOf(baseApk, libsApk, langApk, resApk).forEach { apk ->
runner.logger.i("Reading AndroidManifest.xml from ${apk.name}")
val manifest = ZipReader(apk)
.use { zip -> zip.openEntry("AndroidManifest.xml")?.read() }
?: throw IllegalStateException("No manifest in ${apk.name}")
ZipWriter(apk, true).use { zip ->
runner.logger.i("Changing package and app name in ${apk.name}")
val patchedManifestBytes = if (apk == baseApk) {
ManifestPatcher.patchManifest(
manifestBytes = manifest,
packageName = preferences.packageName,
appName = preferences.appName,
debuggable = preferences.debuggable,
)
} else {
runner.logger.i("Changing package name in ${apk.name}")
ManifestPatcher.renamePackage(manifest, preferences.packageName)
}
runner.logger.i("Deleting old AndroidManifest.xml in ${apk.name}")
zip.deleteEntry(
"AndroidManifest.xml",
/* fillVoid = */ apk == libsApk || apk == baseApk
) // Preserve alignment in libs apk
runner.logger.i("Adding patched AndroidManifest.xml in ${apk.name}")
zip.writeEntry("AndroidManifest.xml", patchedManifestBytes)
}
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/PresignApksStep.kt
================================================
package dev.beefers.vendetta.manager.installer.step.patching
import android.os.Build
import com.github.diamondminer88.zip.ZipCompression
import com.github.diamondminer88.zip.ZipReader
import com.github.diamondminer88.zip.ZipWriter
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.installer.step.Step
import dev.beefers.vendetta.manager.installer.step.StepGroup
import dev.beefers.vendetta.manager.installer.step.StepRunner
import dev.beefers.vendetta.manager.installer.step.download.DownloadBaseStep
import dev.beefers.vendetta.manager.installer.step.download.DownloadLangStep
import dev.beefers.vendetta.manager.installer.step.download.DownloadLibsStep
import dev.beefers.vendetta.manager.installer.step.download.DownloadResourcesStep
import dev.beefers.vendetta.manager.installer.util.Signer
import java.io.File
/**
* Sign all patched apks before being ran through LSPatch, this is required due to LSPatch not liking unsigned apks.
*
* @param signedDir Where to output all signed apks
*/
class PresignApksStep(
private val signedDir: File
) : Step() {
override val group = StepGroup.PATCHING
override val nameRes = R.string.step_signing
override suspend fun run(runner: StepRunner) {
val baseApk = runner.getCompletedStep<DownloadBaseStep>().workingCopy
val libsApk = runner.getCompletedStep<DownloadLibsStep>().workingCopy
val langApk = runner.getCompletedStep<DownloadLangStep>().workingCopy
val resApk = runner.getCompletedStep<DownloadResourcesStep>().workingCopy
runner.logger.i("Creating dir for signed apks: ${signedDir.absolutePath}")
signedDir.mkdirs()
val apks = listOf(baseApk, libsApk, langApk, resApk)
// Align resources.arsc due to targeting api 30 for silent install
if(Build.VERSION.SDK_INT >= 30) {
for (file in apks) {
runner.logger.i("Byte aligning ${file.name}")
val bytes = ZipReader(file).use {
if (it.entryNames.contains("resources.arsc")) {
it.openEntry("resources.arsc")?.read()
} else {
null
}
} ?: continue
ZipWriter(file, true).use {
runner.logger.i("Removing old resources.arsc")
it.deleteEntry("resources.arsc", true)
runner.logger.i("Adding aligned resources.arsc")
it.writeEntry("resources.arsc", bytes, ZipCompression.NONE, 4096)
}
}
}
apks.forEach {
runner.logger.i("Signing ${it.name}")
Signer.signApk(it, File(signedDir, it.name))
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/ReplaceIconStep.kt
================================================
package dev.beefers.vendetta.manager.installer.step.patching
import android.content.Context
import androidx.compose.ui.graphics.Color
import com.github.diamondminer88.zip.ZipWriter
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
import dev.beefers.vendetta.manager.installer.step.Step
import dev.beefers.vendetta.manager.installer.step.StepGroup
import dev.beefers.vendetta.manager.installer.step.StepRunner
import dev.beefers.vendetta.manager.installer.step.download.DownloadBaseStep
import dev.beefers.vendetta.manager.installer.util.ArscUtil
import dev.beefers.vendetta.manager.installer.util.ArscUtil.addColorResource
import dev.beefers.vendetta.manager.installer.util.ArscUtil.getMainArscChunk
import dev.beefers.vendetta.manager.installer.util.ArscUtil.getPackageChunk
import dev.beefers.vendetta.manager.installer.util.ArscUtil.getResourceFileName
import dev.beefers.vendetta.manager.installer.util.AxmlUtil
import dev.beefers.vendetta.manager.utils.DiscordVersion
import org.koin.core.component.inject
/**
* Replaces the existing app icons with Vendetta tinted ones
*/
class ReplaceIconStep : Step() {
private val preferences: PreferenceManager by inject()
val context: Context by inject()
override val group = StepGroup.PATCHING
override val nameRes = R.string.step_change_icon
override suspend fun run(runner: StepRunner) {
val baseApk = runner.getCompletedStep<DownloadBaseStep>().workingCopy
runner.logger.i("Reading resources.arsc")
val arsc = ArscUtil.readArsc(baseApk)
val iconRscIds = AxmlUtil.readManifestIconInfo(baseApk)
val squareIconFile = arsc.getMainArscChunk().getResourceFileName(iconRscIds.squareIcon, "anydpi-v26")
val roundIconFile = arsc.getMainArscChunk().getResourceFileName(iconRscIds.roundIcon, "anydpi-v26")
runner.logger.i("Patching icon assets (squareIcon=$squareIconFile, roundIcon=$roundIconFile)")
val backgroundColor = arsc.getPackageChunk().addColorResource("bunny_color", Color(0xFF48488B))
val postfix = when (preferences.channel) {
DiscordVersion.Type.BETA -> "beta"
DiscordVersion.Type.ALPHA -> "canary"
else -> null
}
for (rscFile in setOf(squareIconFile, roundIconFile)) { // setOf to not possibly patch same file twice
val referencePath = if (postfix == null) rscFile else {
rscFile.replace("_$postfix.xml", ".xml")
}
runner.logger.i("Patching adaptive icon ($rscFile <- $referencePath)")
AxmlUtil.patchAdaptiveIcon(
apk = baseApk,
resourcePath = rscFile,
referencePath = referencePath,
backgroundColor = backgroundColor,
)
}
runner.logger.i("Writing and compiling resources.arsc")
ZipWriter(baseApk, /* append = */ true).use {
it.deleteEntry("resources.arsc")
it.writeEntry("resources.arsc", arsc.toByteArray())
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/util/ArscUtil.kt
================================================
// https://github.com/Aliucord/Manager/blob/main/app/src/main/kotlin/com/aliucord/manager/installer/util/ArscUtil.kt
package dev.beefers.vendetta.manager.installer.util
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.github.diamondminer88.zip.ZipReader
import com.google.devrel.gmscore.tools.apk.arsc.*
import dev.beefers.vendetta.manager.BuildConfig
import java.io.File
object ArscUtil {
/**
* Read and parse `resources.arsc` from an APK.
*/
fun readArsc(apk: File): BinaryResourceFile {
val bytes = ZipReader(apk).use { it.openEntry("resources.arsc")?.read() }
?: error("APK missing resources.arsc")
return try {
BinaryResourceFile(bytes)
} catch (t: Throwable) {
throw Error("Failed to parse resources.arsc", t)
}
}
/**
* Get the only top-level chunk in an arsc file.
*/
fun BinaryResourceFile.getMainArscChunk(): ResourceTableChunk {
if (this.chunks.size > 1)
error("More than 1 top level chunk in resources.arsc")
return this.chunks.first() as? ResourceTableChunk
?: error("Invalid top-level resources.arsc chunk")
}
/**
* Get a singular package chunk in an arsc file.
*/
fun BinaryResourceFile.getPackageChunk(): PackageChunk {
return this.getMainArscChunk().packages.singleOrNull()
?: error("resources.arsc must contain exactly 1 package chunk")
}
/**
* Adds a new color resource to all configuration variants in an arsc package.
*
* @param name The new resource name.
* @param color The value of the new color resource.
* @return The resource ID of the newly added resource.
*/
fun PackageChunk.addColorResource(
name: String,
color: Color,
): BinaryResourceIdentifier {
return this.addResource(
typeName = "color",
resourceName = name,
configurations = { true },
valueType = BinaryResourceValue.Type.INT_COLOR_ARGB8,
valueData = color.toArgb(),
)
}
/**
* Adds a new color resource to the matching configuration variants in an arsc package.
*
* @param typeName The type of the resource (ex: `mipmap`, `drawable`, etc.)
* @param resourceName The new resource name.
* @param configurations A predicate whether to add the value into a matching type chunk.
* @param valueType The type of the resource value.
* @param valueData The raw data of the resource value.
* @return The resource ID of the newly added resource.
*/
fun PackageChunk.addResource(
typeName: String,
resourceName: String,
configurations: (BinaryResourceConfiguration) -> Boolean,
valueType: BinaryResourceValue.Type,
valueData: Int,
): BinaryResourceIdentifier {
// Add a new resource entry to the "type spec chunk" and,
// a new resource entry to all matching "type chunks"
val specChunk = this.getTypeSpecChunk(typeName)
val typeChunks = this.getTypeChunks(typeName)
// Add a new string to the pool to be used as a key
val resourceNameIdx = this.keyStringPool.addString(resourceName, /* deduplicate = */ true)
// Add a new resource entry to the type spec chunk
// HACK: Resource index returned by addResource is off by 1 in release builds due to optimizations or something
val resourceIdx = specChunk.addResource(/* flags = */ 0) + if (BuildConfig.DEBUG) 0 else 1
for (typeChunk in typeChunks) {
// If no matching config, add a null entry and try next chunk
if (!configurations(typeChunk.configuration)) {
typeChunk.addEntry(null)
continue
}
val entry = TypeChunk.Entry(
/* headerSize = */ 8,
/* flags = */ 0,
/* keyIndex = */ resourceNameIdx,
/* value = */
BinaryResourceValue(
/* type = */ valueType,
/* data = */ valueData,
),
/* values = */ null, // not a complex resource
/* parentEntry = */ 0, // not a complex resource
/* parent = */ typeChunk,
)
typeChunk.addEntry(entry)
}
return BinaryResourceIdentifier.create(
/* packageId = */ this.id,
/* typeId = */ specChunk.id,
/* entryId = */ resourceIdx,
)
}
/**
* In an arsc file, for a specific resource in a configuration, get it's value.
*
* @param resourceId The target resource id.
* @param configurationName The target configuration variant of the resource. (ex: `anydpi-v26`, `xxhdpi`, `ldtrl-mpi`, etc.)
* @return The string value of the resource, which should be a file path inside the apk.
*/
fun ResourceTableChunk.getResourceFileName(
resourceId: BinaryResourceIdentifier,
configurationName: String,
): String {
val packageChunk = this.packages.find { it.id == resourceId.packageId() }
?: error("Unable to find target resource")
val typeChunk = packageChunk.getTypeChunks(resourceId.typeId())
.find { it.configuration.toString() == configurationName }
?: error("Unable to find target resource")
val entry = try {
typeChunk.getEntry(resourceId.entryId())!!
} catch (_: Throwable) {
error("Unable to find target resource")
}
if (entry.isComplex || entry.value().type() != BinaryResourceValue.Type.STRING)
error("Target resource value type is not STRING")
val valueIdx = entry.value().data()
val value = this.stringPool.getString(valueIdx)
return value
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/util/AxmlUtil.kt
================================================
// https://github.com/Aliucord/Manager/blob/main/app/src/main/kotlin/com/aliucord/manager/installer/util/AxmlUtil.kt
package dev.beefers.vendetta.manager.installer.util
import dev.beefers.vendetta.manager.utils.find
import com.github.diamondminer88.zip.ZipReader
import com.github.diamondminer88.zip.ZipWriter
import com.google.devrel.gmscore.tools.apk.arsc.*
import java.io.File
object AxmlUtil {
/**
* Read and parse a specific axml resource inside an APK
* @param apk The source apk
* @param resourcePath The full path to the axml file inside the apk, which may be flattened.
*/
private fun readAxml(apk: File, resourcePath: String): BinaryResourceFile {
val bytes = ZipReader(apk).use { it.openEntry(resourcePath)?.read() }
?: error("APK missing resource file at $resourcePath")
return try {
BinaryResourceFile(bytes)
} catch (t: Throwable) {
throw Error("Failed to parse axml at $resourcePath", t)
}
}
/**
* Get the only top-level chunk in an axml file.
*/
private fun BinaryResourceFile.getMainAxmlChunk(): XmlChunk {
if (this.chunks.size > 1)
error("More than 1 top level chunk in axml")
return this.chunks.first() as? XmlChunk
?: error("Invalid top-level axml chunk")
}
/**
* Finds the first chunk with a matching [name] in a flattened chunk list.
* @receiver The top level XmlChunk ([getMainAxmlChunk])
*/
private fun XmlChunk.getStartElementChunk(name: String): XmlStartElementChunk? {
val nameIdx = this.stringPool.indexOf(name)
return this.chunks
.find { it is XmlStartElementChunk && it.nameIndex == nameIdx }
as? XmlStartElementChunk
}
/**
* Finds the first attribute with a matching name (ignoring namespace)
* in a starting element chunk.
*/
private fun XmlStartElementChunk.getAttribute(name: String): XmlAttribute {
val nameIdx = (this.parent as XmlChunk).stringPool.indexOf(name)
return this.attributes
.find { it.nameIndex() == nameIdx }
?: error("Failed to find $name attribute in an axml chunk")
}
/**
* Patches an <adaptive-icon> axml file to change the `background`, `foreground`, and `monochrome` resource references.
* If any of the following are not null, then they will be patched.
* @param backgroundColor A color resource id to replace <background> with.
*
* @param foregroundIcon A drawable resource id to replace <foreground> with.
* @param monochromeIcon A drawable resource id to add or replace <monochrome> with.
*/
fun patchAdaptiveIcon(
apk: File,
resourcePath: String,
referencePath: String,
backgroundColor: BinaryResourceIdentifier? = null,
foregroundIcon: BinaryResourceIdentifier? = null,
monochromeIcon: BinaryResourceIdentifier? = null,
) {
val xml = readAxml(apk, referencePath)
val xmlChunk = xml.getMainAxmlChunk()
// Patch the background color resource reference
if (backgroundColor != null) {
val chunk = xmlChunk.getStartElementChunk("background")!!
val attribute = chunk.getAttribute("drawable")
attribute.typedValue().setValue(
/* type = */ BinaryResourceValue.Type.REFERENCE,
/* data = */ backgroundColor.resourceId(),
)
}
// Patch the foreground drawable reference
if (foregroundIcon != null) {
val chunk = xmlChunk.getStartElementChunk("foreground")!!
val attribute = chunk.getAttribute("drawable")
attribute.typedValue().setValue(
/* type = */ BinaryResourceValue.Type.REFERENCE,
/* data = */ foregroundIcon.resourceId(),
)
}
// Add or replace the monochrome drawable reference
if (monochromeIcon != null) {
// <monochrome> already exists, patch existing chunk
val existingChunk = xmlChunk.getStartElementChunk("monochrome")
if (existingChunk != null) {
val attribute = existingChunk.getAttribute("drawable")
attribute.typedValue().setValue(
/* type = */ BinaryResourceValue.Type.REFERENCE,
/* data = */ monochromeIcon.resourceId(),
)
}
// Add a new start & end chunk since they don't exist
// `<monochrome android:drawable="@drawable/xyz"></monochrome>
else {
val iconEndChunkIdx = xmlChunk.chunks
.indexOfLast { it is XmlEndElementChunk && it.name == "adaptive-icon" }
val namespaceIdx = xmlChunk.stringPool.indexOf("http://schemas.android.com/apk/res/android")
val drawableIdx = xmlChunk.stringPool.indexOf("drawable")
val monochromeIdx = xmlChunk.stringPool.addString("monochrome")
val startChunk = XmlStartElementChunk(
/* namespaceIndex = */ -1,
/* nameIndex = */ monochromeIdx,
/* idIndex = */ -1,
/* classIndex = */ -1,
/* styleIndex = */ -1,
/* attributes = */
listOf(
XmlAttribute(
/* namespaceIndex = */ namespaceIdx,
/* nameIndex = */ drawableIdx,
/* rawValueIndex = */ -1,
/* typedValue = */
BinaryResourceValue(
/* type = */ BinaryResourceValue.Type.REFERENCE,
/* data = */ monochromeIcon.resourceId(),
),
// This is wrong but it doesn't matter here as long as this attribute isn't stringified
/* parent = */ null,
)
),
/* parent = */ xmlChunk,
)
val endChunk = XmlEndElementChunk(
/* namespaceIndex = */ namespaceIdx,
/* nameIndex = */ monochromeIdx,
/* parent = */ xmlChunk,
)
xmlChunk.addChunk(iconEndChunkIdx, startChunk)
xmlChunk.addChunk(iconEndChunkIdx + 1, endChunk)
}
}
ZipWriter(apk, /* append = */ true).use { zip ->
zip.deleteEntry(resourcePath)
zip.writeEntry(resourcePath, xml.toByteArray())
}
}
/**
* From an APK, read the manifest's `icon` and `roundIcon` references to a resource.
* This is then used to get the filename of the resource from `resources.arsc`.
*/
fun readManifestIconInfo(apk: File): ManifestIconInfo {
val manifestBytes = ZipReader(apk).use { it.openEntry("AndroidManifest.xml")?.read() }
?: error("APK missing manifest")
val manifest = BinaryResourceFile(manifestBytes)
val mainChunk = manifest.getMainAxmlChunk()
// Prefetch string indexes to avoid parsing the entire string pool
val iconStringIdx = mainChunk.stringPool.indexOf("icon")
val roundIconStringIdx = mainChunk.stringPool.indexOf("roundIcon")
val applicationStringIdx = mainChunk.stringPool.indexOf("application")
val applicationChunk = mainChunk.chunks
.find { it is XmlStartElementChunk && it.nameIndex == applicationStringIdx } as? XmlStartElementChunk
?: error("Unable to find <application> in manifest")
val squareIcon = applicationChunk.attributes
.find { it.nameIndex() == iconStringIdx }
?: error("Unable to find android:icon in manifest")
val roundIcon = applicationChunk.attributes
.find { it.nameIndex() == roundIconStringIdx }
?: error("Unable to find android:roundIcon in manifest")
assert(squareIcon.typedValue().type() == BinaryResourceValue.Type.REFERENCE)
assert(roundIcon.typedValue().type() == BinaryResourceValue.Type.REFERENCE)
return ManifestIconInfo(
// Resource IDs into resources.arsc
squareIcon = BinaryResourceIdentifier.create(squareIcon.typedValue().data()),
roundIcon = BinaryResourceIdentifier.create(roundIcon.typedValue().data()),
)
}
data class ManifestIconInfo(
val squareIcon: BinaryResourceIdentifier,
val roundIcon: BinaryResourceIdentifier,
)
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/util/LogEntry.kt
================================================
package dev.beefers.vendetta.manager.installer.util
import android.annotation.SuppressLint
import android.os.Parcel
import android.os.Parcelable
import androidx.compose.runtime.Stable
import kotlinx.datetime.Clock
import java.io.Serializable
import java.text.SimpleDateFormat
import java.util.Date
@Stable
data class LogEntry(
val message: String,
val level: Level,
private val timestampMillis: Long = Clock.System.now().toEpochMilliseconds()
): Parcelable, Serializable {
override fun describeContents() = 0
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(message)
dest.writeInt(level.ordinal)
dest.writeLong(timestampMillis)
}
override fun toString(): String {
return "${formatTimestamp()} [${level.name[0]}] $message"
}
@SuppressLint("SimpleDateFormat")
fun formatTimestamp(): String {
return SimpleDateFormat("MM-dd-yyyy h:mm:ssa").format(Date(timestampMillis))
}
enum class Level {
DEBUG,
INFO,
ERROR
}
companion object CREATOR: Parcelable.Creator<LogEntry?> {
override fun createFromParcel(source: Parcel): LogEntry {
val message = source.readString()!!
val level = Level.entries[source.readInt()]
val timestamp = source.readLong()
return LogEntry(message, level, timestamp)
}
override fun newArray(size: Int): Array<LogEntry?> {
return arrayOfNulls(size)
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/util/Logger.kt
================================================
package dev.beefers.vendetta.manager.installer.util
import android.util.Log
import androidx.compose.runtime.Stable
import org.lsposed.patch.util.Logger
/**
* Used to log events done during the patching process
*
* @param tag The tag to use for logcat
*/
@Stable
class Logger(
private val tag: String
): Logger() {
/**
* All logs made with this [Logger]
*/
val logs = mutableListOf<LogEntry>()
/**
* Prints a debug message to logcat and stores it in [logs]
*/
override fun d(msg: String?) {
log(msg, LogEntry.Level.DEBUG)
Log.d(tag, msg.toString())
}
/**
* Prints an info message to logcat and stores it in [logs]
*/
override fun i(msg: String?) {
log(msg, LogEntry.Level.INFO)
Log.i(tag, msg.toString())
}
/**
* Prints an error message to logcat and stores it in [logs]
*/
override fun e(msg: String?) {
log(msg, LogEntry.Level.ERROR)
Log.e(tag, msg.toString())
}
/**
* Prints an error message and stacktrace with a preceding empty line.
*/
fun e(msg: String?, th: Throwable?) {
newline()
e(msg)
if (th != null) e(th.stackTraceToString().trim())
}
/**
* Stores a log entry
*/
private fun log(msg: String?, level: LogEntry.Level) {
msg?.let {
msg.split("\n").forEach {
logs += LogEntry(it, level)
}
}
}
/**
* Prints an empty line
*/
private fun newline() {
i("\n")
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/util/ManifestPatcher.kt
================================================
package dev.beefers.vendetta.manager.installer.util
import android.Manifest
import pxb.android.axml.AxmlReader
import pxb.android.axml.AxmlVisitor
import pxb.android.axml.AxmlWriter
import pxb.android.axml.NodeVisitor
object ManifestPatcher {
private const val ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android"
private const val USES_CLEARTEXT_TRAFFIC = "usesCleartextTraffic"
private const val DEBUGGABLE = "debuggable"
private const val REQUEST_LEGACY_EXTERNAL_STORAGE = "requestLegacyExternalStorage"
private const val NETWORK_SECURITY_CONFIG = "networkSecurityConfig"
private const val LABEL = "label"
private const val PACKAGE = "package"
private const val COMPILE_SDK_VERSION = "compileSdkVersion"
private const val COMPILE_SDK_VERSION_CODENAME = "compileSdkVersionCodename"
private const val DRNE_PERMISSION = "DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"
private const val DISCORD_DRNE_PERMISSION = "com.discord.$DRNE_PERMISSION"
fun patchManifest(
manifestBytes: ByteArray,
packageName: String,
appName: String,
debuggable: Boolean
): ByteArray {
val reader = AxmlReader(manifestBytes)
val writer = AxmlWriter()
reader.accept(object : AxmlVisitor(writer) {
override fun child(ns: String?, name: String?) =
object : ReplaceAttrsVisitor(
super.child(ns, name),
mapOf(
PACKAGE to packageName,
COMPILE_SDK_VERSION to 23,
COMPILE_SDK_VERSION_CODENAME to "6.0-2438415"
)
) {
private var addExternalStoragePerm = false
override fun child(ns: String?, name: String): NodeVisitor {
val nv = super.child(ns, name)
// Add MANAGE_EXTERNAL_STORAGE when necessary
if (addExternalStoragePerm) {
super
.child(null, "uses-permission")
.attr(
ANDROID_NAMESPACE,
"name",
android.R.attr.name,
TYPE_STRING,
Manifest.permission.MANAGE_EXTERNAL_STORAGE
)
addExternalStoragePerm = false
}
return when (name) {
"permission" -> object : NodeVisitor(nv) {
override fun attr(
ns: String?,
name: String?,
resourceId: Int,
type: Int,
value: Any?
) {
if(name == "name" && value == DISCORD_DRNE_PERMISSION) {
super.attr(ns, name, resourceId, type, "$packageName.$DRNE_PERMISSION")
} else {
super.attr(ns, name, resourceId, type, value)
}
}
}
"uses-permission" -> object : NodeVisitor(nv) {
override fun attr(
ns: String?,
name: String?,
resourceId: Int,
type: Int,
value: Any?
) {
if(name == "name" && value == DISCORD_DRNE_PERMISSION) {
super.attr(ns, name, resourceId, type, "$packageName.$DRNE_PERMISSION")
}
if (name != "maxSdkVersion") {
super.attr(ns, name, resourceId, type, value)
}
// Set the add external storage permission to be added after WRITE_EXTERNAL_STORAGE (which is after read)
if (name == "name" && value == Manifest.permission.READ_EXTERNAL_STORAGE) {
addExternalStoragePerm = true
}
}
}
"application" -> object : ReplaceAttrsVisitor(
nv,
mapOf(
LABEL to appName,
DEBUGGABLE to debuggable,
USES_CLEARTEXT_TRAFFIC to true,
REQUEST_LEGACY_EXTERNAL_STORAGE to true
)
) {
private var addDebuggable = debuggable
private var addLegacyStorage = true
private var addUseClearTextTraffic = true
override fun attr(
ns: String?,
name: String,
resourceId: Int,
type: Int,
value: Any?
) {
super.attr(ns, name, resourceId, type, value)
if (name == REQUEST_LEGACY_EXTERNAL_STORAGE) addLegacyStorage =
false
if (name == DEBUGGABLE) addDebuggable = false
if (name == USES_CLEARTEXT_TRAFFIC) addUseClearTextTraffic =
false
}
override fun child(ns: String?, name: String): NodeVisitor {
val visitor = super.child(ns, name)
return when (name) {
"activity" -> ReplaceAttrsVisitor(
visitor,
mapOf("label" to appName)
)
"provider" -> object : NodeVisitor(visitor) {
override fun attr(
ns: String?,
name: String,
resourceId: Int,
type: Int,
value: Any?
) {
super.attr(
ns,
name,
resourceId,
type,
if (name == "authorities") {
(value as String).replace(
"com.discord",
packageName
)
} else {
value
}
)
}
}
else -> visitor
}
}
override fun end() {
if (addLegacyStorage) super.attr(
ANDROID_NAMESPACE,
REQUEST_LEGACY_EXTERNAL_STORAGE,
-1,
TYPE_INT_BOOLEAN,
1
)
if (addDebuggable) super.attr(
ANDROID_NAMESPACE,
DEBUGGABLE,
-1,
TYPE_INT_BOOLEAN,
1
)
if (addUseClearTextTraffic) super.attr(
ANDROID_NAMESPACE,
USES_CLEARTEXT_TRAFFIC,
-1,
TYPE_INT_BOOLEAN,
1
)
super.end()
}
}
else -> nv
}
}
}
})
return writer.toByteArray()
}
fun renamePackage(
manifestBytes: ByteArray,
packageName: String
): ByteArray {
val reader = AxmlReader(manifestBytes)
val writer = AxmlWriter()
reader.accept(
object : AxmlVisitor(writer) {
override fun child(ns: String?, name: String?): ReplaceAttrsVisitor {
return ReplaceAttrsVisitor(
super.child(ns, name),
mapOf("package" to packageName)
)
}
}
)
return writer.toByteArray()
}
private open class ReplaceAttrsVisitor(
nv: NodeVisitor,
private val attrs: Map<String, Any>
) : NodeVisitor(nv) {
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
val replace = attrs.containsKey(name)
val newValue = attrs[name]
super.attr(
ns,
name,
resourceId,
if (newValue is String) TYPE_STRING else type,
if (replace) newValue else value
)
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/util/Patcher.kt
================================================
package dev.beefers.vendetta.manager.installer.util
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.lsposed.patch.LSPatch
import org.lsposed.patch.util.Logger
import java.io.File
object Patcher {
suspend fun patch(
logger: Logger,
outputDir: File,
apkPaths: List<String>,
embeddedModules: List<String>
) {
withContext(Dispatchers.IO) {
LSPatch(
logger,
*apkPaths.toTypedArray(),
"-o",
outputDir.absolutePath,
"-l",
"0",
"-v",
"-m",
*embeddedModules.toTypedArray(),
"-k",
Signer.keyStore.absolutePath,
"password",
"alias",
"password"
).doCommandLine()
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/installer/util/Signer.kt
================================================
package dev.beefers.vendetta.manager.installer.util
import android.content.Context
import com.android.apksig.ApkSigner
import dev.beefers.vendetta.manager.utils.Constants
import org.bouncycastle.asn1.x500.X500Name
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
import org.bouncycastle.cert.X509v3CertificateBuilder
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
import java.math.BigInteger
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.security.SecureRandom
import java.security.cert.Certificate
import java.security.cert.X509Certificate
import java.util.Date
import java.util.Locale
import kotlin.io.path.Path
import kotlin.io.path.moveTo
object Signer : KoinComponent {
private val password = "password".toCharArray()
private val context by inject<Context>()
private val cacheDir = context.cacheDir
private val filesDir = context.filesDir
val keyStore: File by lazy {
val ks = filesDir.resolve("ks.keystore")
migrate(cacheDir, filesDir)
migrate(Constants.VENDETTA_DIR, filesDir)
ks.also {
if (!it.exists()) {
it.createNewFile()
newKeystore(it)
}
}
ks
}
private val signerConfig: ApkSigner.SignerConfig by lazy {
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
this.keyStore.inputStream().use { stream ->
keyStore.load(stream, null)
}
val alias = keyStore.aliases().nextElement()
val certificate = keyStore.getCertificate(alias) as X509Certificate
ApkSigner.SignerConfig.Builder(
"Bunny",
keyStore.getKey(alias, password) as PrivateKey,
listOf(certificate)
).build()
}
private fun newKeystore(out: File) {
val key = createKey()
with(KeyStore.getInstance(KeyStore.getDefaultType())) {
load(null, password)
setKeyEntry("alias", key.privateKey, password, arrayOf<Certificate>(key.publicKey))
store(out.outputStream(), password)
}
}
private fun createKey(): KeySet {
var serialNumber: BigInteger
do serialNumber = SecureRandom().nextInt().toBigInteger()
while (serialNumber < BigInteger.ZERO)
val x500Name = X500Name("CN=Bunny Manager")
val pair = KeyPairGenerator.getInstance("RSA").run {
initialize(2048)
generateKeyPair()
}
val builder = X509v3CertificateBuilder(
/* issuer = */ x500Name,
/* serial = */
serialNumber,
/* notBefore = */
Date(System.currentTimeMillis() - 1000L * 60L * 60L * 24L * 30L),
/* notAfter = */
Date(System.currentTimeMillis() + 1000L * 60L * 60L * 24L * 366L * 30L),
/* dateLocale = */
Locale.ENGLISH,
/* subject = */
x500Name,
/* publicKeyInfo = */
SubjectPublicKeyInfo.getInstance(pair.public.encoded)
)
val signer = JcaContentSignerBuilder("SHA1withRSA").build(pair.private)
return KeySet(
JcaX509CertificateConverter().getCertificate(builder.build(signer)),
pair.private
)
}
fun signApk(apkFile: File, output: File) {
val outputApk = cacheDir.resolve(apkFile.name)
ApkSigner.Builder(listOf(signerConfig))
.setV1SigningEnabled(false) // TODO: enable so api <24 devices can work, however zip-alignment breaks
.setV2SigningEnabled(true)
.setV3SigningEnabled(true)
.setInputApk(apkFile)
.setOutputApk(output)
.build()
.sign()
outputApk.renameTo(apkFile)
}
private fun migrate(oldDir: File, newDir: File) {
oldDir.resolve("ks.keystore").also {
if (it.exists()) {
Path(it.absolutePath)
.moveTo(
Path(newDir.resolve("ks.keystore").absolutePath),
overwrite = true
)
}
}
}
private class KeySet(val publicKey: X509Certificate, val privateKey: PrivateKey)
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/network/dto/Commit.kt
================================================
package dev.beefers.vendetta.manager.network.dto
import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Commit(
val sha: String,
@SerialName("commit") val info: Info,
@SerialName("html_url") val url: String,
val author: User?
) {
@Serializable
data class Info(
val message: String,
val committer: Committer
)
@Serializable
data class Committer(
val date: Instant
)
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/network/dto/Index.kt
================================================
package dev.beefers.vendetta.manager.network.dto
import kotlinx.serialization.Serializable
@Serializable
data class Index(
val latest: Versions
) {
@Serializable
data class Versions(
val alpha: String,
val beta: String,
val stable: String
)
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/network/dto/Release.kt
================================================
package dev.beefers.vendetta.manager.network.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Release(
@SerialName("tag_name") val tagName: String,
@SerialName("name") val versionName: String
)
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/network/dto/User.kt
================================================
package dev.beefers.vendetta.manager.network.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class User(
@SerialName("login") val username: String,
@SerialName("avatar_url") val avatar: String
)
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/network/service/HttpService.kt
================================================
package dev.beefers.vendetta.manager.network.service
import dev.beefers.vendetta.manager.network.utils.ApiError
import dev.beefers.vendetta.manager.network.utils.ApiFailure
import dev.beefers.vendetta.manager.network.utils.ApiResponse
import io.ktor.client.HttpClient
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.request
import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess
import kotlinx.serialization.json.Json
class HttpService(
val json: Json,
val http: HttpClient
) {
suspend inline fun <reified T> request(builder: HttpRequestBuilder.() -> Unit = {}): ApiResponse<T> {
var body: String? = null
val response = try {
val response = http.request(builder)
if (response.status.isSuccess()) {
body = response.bodyAsText()
if (T::class == String::class) {
return ApiResponse.Success(body as T)
}
ApiResponse.Success(json.decodeFromString<T>(body))
} else {
body = try {
response.bodyAsText()
} catch (e: Throwable) {
null
}
ApiResponse.Error(ApiError(response.status, body))
}
} catch (e: Throwable) {
ApiResponse.Failure(ApiFailure(e, body))
}
return response
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/network/service/RestService.kt
================================================
package dev.beefers.vendetta.manager.network.service
import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
import dev.beefers.vendetta.manager.network.dto.Commit
import dev.beefers.vendetta.manager.network.dto.Index
import dev.beefers.vendetta.manager.network.dto.Release
import io.ktor.client.request.parameter
import io.ktor.client.request.url
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class RestService(
private val httpService: HttpService,
private val prefs: PreferenceManager
) {
suspend fun getLatestRelease(repo: String) = withContext(Dispatchers.IO) {
httpService.request<Release> {
url("https://api.github.com/repos/$repo/releases/latest")
}
}
suspend fun getLatestDiscordVersions() = withContext(Dispatchers.IO) {
httpService.request<Index> {
url("${prefs.mirror.baseUrl}/tracker/index")
}
}
suspend fun getCommits(repo: String, page: Int = 1) = withContext(Dispatchers.IO) {
httpService.request<List<Commit>> {
url("https://api.github.com/repos/$repo/commits")
parameter("page", page)
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/network/utils/ApiResponse.kt
================================================
package dev.beefers.vendetta.manager.network.utils
import io.ktor.http.HttpStatusCode
sealed interface ApiResponse<D> {
data class Success<D>(val data: D) : ApiResponse<D>
data class Error<D>(val error: ApiError) : ApiResponse<D>
data class Failure<D>(val error: ApiFailure) : ApiResponse<D>
}
class ApiError(code: HttpStatusCode, body: String?) : Error("HTTP Code $code, Body: $body")
class ApiFailure(error: Throwable, body: String?) : Error(body, error)
val <D> ApiResponse<D>.dataOrNull
get() = if (this is ApiResponse.Success) data else null
val <D> ApiResponse<D>.dataOrThrow
get() = when (this) {
is ApiResponse.Success -> data
is ApiResponse.Error -> throw error
is ApiResponse.Failure -> throw error
}
inline fun <D> ApiResponse<D>.ifSuccessful(block: (D) -> Unit) {
if (this is ApiResponse.Success) block(data)
}
fun <D> ApiResponse<D>.fold(
onSuccess: (D) -> Unit = {},
onError: () -> Unit = {}
) {
when (this) {
is ApiResponse.Success -> onSuccess(data)
is ApiResponse.Error,
is ApiResponse.Failure -> onError()
}
}
@Suppress("UNCHECKED_CAST")
fun <T, R> ApiResponse<T>.transform(block: (T) -> R): ApiResponse<R> {
return when (this) {
is ApiResponse.Success -> ApiResponse.Success(block(data))
is ApiResponse.Error -> this as ApiResponse.Error<R>
is ApiResponse.Failure -> this as ApiResponse.Failure<R>
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/network/utils/CommitsPagingSource.kt
================================================
package dev.beefers.vendetta.manager.network.utils
import androidx.paging.PagingSource
import androidx.paging.PagingState
import dev.beefers.vendetta.manager.domain.repository.RestRepository
import dev.beefers.vendetta.manager.network.dto.Commit
class CommitsPagingSource(
private val repo: RestRepository
) : PagingSource<Int, Commit>() {
override fun getRefreshKey(state: PagingState<Int, Commit>): Int? =
state.anchorPosition?.let {
state.closestPageToPosition(it)?.prevKey
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Commit> {
val page = params.key ?: 0
return when (val response = repo.getCommits("pyoncord/Bunny", page)) {
is ApiResponse.Success -> LoadResult.Page(
data = response.data,
prevKey = if (page > 0) page - 1 else null,
nextKey = if (response.data.isNotEmpty()) page + 1 else null
)
is ApiResponse.Failure -> LoadResult.Error(response.error)
is ApiResponse.Error -> LoadResult.Error(response.error)
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/activity/MainActivity.kt
================================================
package dev.beefers.vendetta.manager.ui.activity
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.app.ActivityCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.transitions.SlideTransition
import dev.beefers.vendetta.manager.domain.manager.InstallMethod
import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
import dev.beefers.vendetta.manager.installer.shizuku.ShizukuPermissions
import dev.beefers.vendetta.manager.ui.screen.home.HomeScreen
import dev.beefers.vendetta.manager.ui.screen.installer.InstallerScreen
import dev.beefers.vendetta.manager.ui.theme.VendettaManagerTheme
import dev.beefers.vendetta.manager.utils.DiscordVersion
import dev.beefers.vendetta.manager.utils.Intents
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
class MainActivity : ComponentActivity() {
private val preferences: PreferenceManager by inject()
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val version = intent.getStringExtra(Intents.Extras.VERSION)
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(
this,
arrayOf("android.permission.POST_NOTIFICATIONS"),
0
)
}
if (preferences.installMethod == InstallMethod.SHIZUKU) {
lifecycleScope.launch {
if (!ShizukuPermissions.waitShizukuPermissions()) {
preferences.installMethod = InstallMethod.DEFAULT
}
}
}
val screen = if (intent.action == Intents.Actions.INSTALL && version != null) {
InstallerScreen(DiscordVersion.fromVersionCode(version)!!)
} else {
HomeScreen()
}
setContent {
VendettaManagerTheme {
Navigator(screen) {
SlideTransition(it)
}
}
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/components/Label.kt
================================================
package dev.beefers.vendetta.manager.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun Label(
modifier: Modifier = Modifier,
text: String? = null,
icon: ImageVector? = null,
textColor: Color,
borderColor: Color = textColor,
fillColor: Color = Color.Transparent,
iconColor: Color = textColor
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.then(modifier)
.clip(CircleShape)
.background(fillColor)
.border(1.dp, borderColor, CircleShape)
.then(
if (text == null)
Modifier.padding(5.dp)
else
Modifier.padding(vertical = 5.dp, horizontal = 7.dp)
)
) {
icon?.let {
Icon(
imageVector = it,
contentDescription = null,
tint = iconColor,
modifier = Modifier.size(12.dp)
)
}
text?.let {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
fontSize = 10.sp,
color = textColor,
maxLines = 1
)
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/components/NavBarSpacer.kt
================================================
package dev.beefers.vendetta.manager.ui.components
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.beefers.vendetta.manager.utils.DimenUtils
@Composable
fun NavBarSpacer() {
Spacer(
modifier = Modifier.height(DimenUtils.navBarPadding)
)
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/components/RadioController.kt
================================================
package dev.beefers.vendetta.manager.ui.components
import androidx.compose.foundation.clickable
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.material3.RadioButton
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.Alignment
import androidx.compose.ui.Modifier
@Composable
inline fun <reified E : Enum<E>> EnumRadioController(
default: E,
excludedOptions: List<E> = emptyList(),
labelFactory: (E) -> String = { it.toString() },
crossinline onChoiceSelected: (E) -> Unit
) {
var choice by remember { mutableStateOf(default) }
Column {
enumValues<E>().filterNot { excludedOptions.contains(it) }.forEach {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
choice = it
onChoiceSelected(it)
},
verticalAlignment = Alignment.CenterVertically
) {
Text(labelFactory(it))
Spacer(Modifier.weight(1f))
RadioButton(
selected = it == choice,
onClick = {
choice = it
onChoiceSelected(it)
})
}
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/components/SegmentedButton.kt
================================================
package dev.beefers.vendetta.manager.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun RowScope.SegmentedButton(
icon: Any,
iconDescription: String? = null,
text: String,
onClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
modifier = Modifier
.clickable(onClick = onClick)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp))
.weight(1f)
.padding(16.dp)
) {
when (icon) {
is ImageVector -> {
Icon(
imageVector = icon,
contentDescription = iconDescription,
tint = MaterialTheme.colorScheme.primary
)
}
is Painter -> {
Icon(
painter = icon,
contentDescription = iconDescription,
tint = MaterialTheme.colorScheme.primary
)
}
}
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
modifier = Modifier.basicMarquee()
)
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/components/ThinDivider.kt
================================================
package dev.beefers.vendetta.manager.ui.components
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
@Composable
fun ThinDivider() = HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.onSurface.copy(0.1f)
)
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsButton.kt
================================================
package dev.beefers.vendetta.manager.ui.components.settings
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun SettingsButton(
label: String,
onClick: () -> Unit = {}
) {
Box(
modifier = Modifier
.heightIn(min = 64.dp)
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 14.dp)
) {
Button(onClick, modifier = Modifier.fillMaxWidth()) {
Text(text = label)
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsCategory.kt
================================================
package dev.beefers.vendetta.manager.ui.components.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import dev.beefers.vendetta.manager.utils.navigate
@Composable
fun SettingsCategory(
icon: ImageVector,
text: String,
subtext: String,
destination: (() -> Screen)? = null
) {
val screen = destination?.invoke()
val nav = LocalNavigator.current
Box(
modifier = Modifier
.clickable {
screen?.let { nav?.navigate(it) }
}
) {
SettingsItem(
icon = { Icon(icon, null) },
text = { Text(text) },
secondaryText = { Text(subtext) }
)
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsChoiceDialog.kt
================================================
package dev.beefers.vendetta.manager.ui.components.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilledTonalButton
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.res.stringResource
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.ui.components.EnumRadioController
@Composable
inline fun <reified E : Enum<E>> SettingsChoiceDialog(
visible: Boolean = false,
default: E,
excludedOptions: List<E> = emptyList(),
noinline title: @Composable () -> Unit,
crossinline labelFactory: (E) -> String = { it.toString() },
noinline onRequestClose: () -> Unit = {},
crossinline description: @Composable () -> Unit = {},
noinline onChoice: (E) -> Unit = {},
) {
var choice by remember { mutableStateOf(default) }
AnimatedVisibility(
visible = visible,
enter = slideInVertically(),
exit = slideOutVertically()
) {
AlertDialog(
onDismissRequest = { onRequestClose() },
title = title,
text = {
description()
EnumRadioController(
default,
excludedOptions,
labelFactory
) { choice = it }
},
confirmButton = {
FilledTonalButton(onClick = { onChoice(choice) }) {
Text(text = stringResource(id = R.string.action_confirm))
}
}
)
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsHeader.kt
================================================
package dev.beefers.vendetta.manager.ui.components.settings
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun SettingsHeader(
text: String
) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(18.dp, 24.dp, 18.dp, 10.dp)
)
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsItem.kt
================================================
package dev.beefers.vendetta.manager.ui.components.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun SettingsItem(
modifier: Modifier = Modifier,
icon: @Composable (() -> Unit)? = null,
text: @Composable () -> Unit,
secondaryText: @Composable (() -> Unit) = { },
trailing: @Composable (() -> Unit) = { },
) {
Row(
modifier = modifier
.heightIn(min = 64.dp)
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) Box(modifier = Modifier.padding(8.dp)) {
icon()
}
Column(
verticalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier.weight(1f, true)
) {
ProvideTextStyle(
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Normal,
fontSize = 19.sp
)
) {
text()
}
ProvideTextStyle(
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface.copy(0.6f)
)
) {
secondaryText()
}
}
trailing()
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsItemChoice.kt
================================================
package dev.beefers.vendetta.manager.ui.components.settings
import androidx.compose.foundation.clickable
import androidx.compose.material3.FilledTonalButton
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.platform.LocalContext
@Composable
inline fun <reified E : Enum<E>> SettingsItemChoice(
label: String,
title: String = label,
disabled: Boolean = false,
pref: E,
excludedOptions: List<E> = emptyList(),
crossinline labelFactory: (E) -> String = { it.toString() },
crossinline onPrefChange: (E) -> Unit,
) {
val ctx = LocalContext.current
val choiceLabel = labelFactory(pref)
val opened = remember {
mutableStateOf(false)
}
SettingsItem(
modifier = Modifier.clickable(enabled = !disabled) { opened.value = true },
text = { Text(text = label) },
) {
SettingsChoiceDialog(
visible = opened.value,
title = { Text(title) },
default = pref,
labelFactory = labelFactory,
excludedOptions = excludedOptions,
onRequestClose = {
opened.value = false
},
onChoice = {
opened.value = false
onPrefChange(it)
}
)
FilledTonalButton(onClick = { opened.value = true }, enabled = !disabled) {
Text(choiceLabel)
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsSwitch.kt
================================================
package dev.beefers.vendetta.manager.ui.components.settings
import androidx.compose.foundation.clickable
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun SettingsSwitch(
label: String,
secondaryLabel: String? = null,
disabled: Boolean = false,
pref: Boolean,
onPrefChange: (Boolean) -> Unit,
) {
SettingsItem(
modifier = Modifier.clickable(enabled = !disabled) { onPrefChange(!pref) },
text = { Text(text = label) },
secondaryText = {
secondaryLabel?.let {
Text(text = it)
}
}
) {
Switch(
checked = pref,
enabled = !disabled,
onCheckedChange = { onPrefChange(!pref) }
)
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsTextField.kt
================================================
package dev.beefers.vendetta.manager.ui.components.settings
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun SettingsTextField(
label: String,
disabled: Boolean = false,
pref: String,
error: Boolean = false,
supportingText: String? = null,
onPrefChange: (String) -> Unit,
) {
Box(modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp)) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = pref,
onValueChange = onPrefChange,
enabled = !disabled,
label = { Text(label) },
isError = error,
singleLine = true,
supportingText = if (supportingText != null) { -> Text(supportingText) } else null
)
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/screen/about/AboutScreen.kt
================================================
package dev.beefers.vendetta.manager.ui.screen.about
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.ArrowBack
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.graphics.asImageBitmap
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import dev.beefers.vendetta.manager.BuildConfig
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
import dev.beefers.vendetta.manager.ui.screen.libraries.LibrariesScreen
import dev.beefers.vendetta.manager.ui.widgets.about.LinkItem
import dev.beefers.vendetta.manager.ui.widgets.about.ListItem
import dev.beefers.vendetta.manager.ui.widgets.about.UserEntry
import dev.beefers.vendetta.manager.utils.Constants
import dev.beefers.vendetta.manager.utils.DimenUtils
import dev.beefers.vendetta.manager.utils.getBitmap
import dev.beefers.vendetta.manager.utils.showToast
import org.koin.compose.koinInject
class AboutScreen : Screen {
@Composable
@OptIn(ExperimentalMaterial3Api::class)
override fun Content() {
val uriHandler = LocalUriHandler.current
val prefs: PreferenceManager = koinInject()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val ctx = LocalContext.current
val bitmap = remember {
ctx.getBitmap(R.drawable.ic_launcher, 60).asImageBitmap()
}
var tapCount by remember {
mutableIntStateOf(0)
}
Scaffold(
topBar = { TitleBar(scrollBehavior) },
contentWindowInsets = WindowInsets(0, 0, 0, 0),
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
) {
Column(
modifier = Modifier
.padding(it)
.verticalScroll(rememberScrollState())
.padding(bottom = DimenUtils.navBarPadding)
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, bottom = 20.dp)
) {
Image(
bitmap = bitmap,
contentDescription = null,
modifier = Modifier
.size(60.dp)
.clip(CircleShape)
)
Text(
stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge
)
Text(
text = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
style = MaterialTheme.typography.labelLarge,
color = LocalContentColor.current.copy(alpha = 0.5f),
modifier = Modifier.clickable(
enabled = !prefs.isDeveloper
) {
tapCount++
when (tapCount) {
3 -> ctx.showToast(R.string.msg_seven_left)
5 -> ctx.showToast(R.string.msg_five_left)
8 -> ctx.showToast(R.string.msg_two_left)
10 -> {
ctx.showToast(R.string.msg_unlocked)
prefs.isDeveloper = true
}
}
}
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth()
) {
LinkItem(
icon = R.drawable.ic_github,
label = R.string.label_github,
link = "https://github.com/bunny-mod"
)
LinkItem(
icon = R.drawable.ic_discord,
label = R.string.label_discord,
link = "https://discord.gg/XjYgWXHb9Q"
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, bottom = 20.dp)
) {
UserEntry("Fiery", "Lead dev\niOS", "FieryFlames")
UserEntry("Maisy", "Creator\nVendetta", "maisymoe", isLarge = true)
UserEntry("Wing", "Lead dev\nManager", "wingio")
}
Text(
text = stringResource(R.string.label_team),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.primary
)
Box(
modifier = Modifier.padding(horizontal = 16.dp)
) {
ElevatedCard {
Constants.TEAM_MEMBERS.forEachIndexed { i, member ->
ListItem(
text = member.name,
subtext = member.role,
imageUrl = "https://github.com/${member.username}.png",
onClick = {
uriHandler.openUri("https://github.com/${member.username}")
}
)
if (i != Constants.TEAM_MEMBERS.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
}
}
}
}
Text(
text = stringResource(R.string.label_special_thanks),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.primary
)
Box(
modifier = Modifier.padding(horizontal = 16.dp)
) {
ElevatedCard {
ListItem(
text = "rushii",
subtext = "Installer, zip library, and a portion of patching",
imageUrl = "https://github.com/rushiiMachine.png",
onClick = {
uriHandler.openUri("https://github.com/rushiiMachine")
}
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
ListItem(
text = "Xinto",
subtext = "for the preference manager",
imageUrl = "https://github.com/X1nto.png",
onClick = {
uriHandler.openUri("https://github.com/X1nto")
}
)
}
}
Box(
modifier = Modifier.padding(16.dp)
) {
ElevatedCard {
val navigator = LocalNavigator.currentOrThrow
// ListItem(
// text = stringResource(R.string.label_translate),
// onClick = { uriHandler.openUri("https://crowdin.com/project/vendetta-manager") }
// )
// HorizontalDivider(
// modifier = Modifier.padding(horizontal = 16.dp),
// thickness = 0.5.dp,
// color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
// )
ListItem(
text = stringResource(R.string.title_os_libraries),
onClick = { navigator.push(LibrariesScreen()) }
)
}
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun TitleBar(
scrollBehavior: TopAppBarScrollBehavior
) {
val navigator = LocalNavigator.currentOrThrow
TopAppBar(
title = { Text(stringResource(R.string.title_about)) },
scrollBehavior = scrollBehavior,
navigationIcon = {
IconButton(onClick = { navigator.pop() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back)
)
}
}
)
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/screen/home/HomeScreen.kt
================================================
package dev.beefers.vendetta.manager.ui.screen.home
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.paging.compose.collectAsLazyPagingItems
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import dev.beefers.vendetta.manager.BuildConfig
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
import dev.beefers.vendetta.manager.ui.components.SegmentedButton
import dev.beefers.vendetta.manager.ui.screen.installer.InstallerScreen
import dev.beefers.vendetta.manager.ui.screen.settings.SettingsScreen
import dev.beefers.vendetta.manager.ui.viewmodel.home.HomeViewModel
import dev.beefers.vendetta.manager.ui.widgets.AppIcon
import dev.beefers.vendetta.manager.ui.widgets.dialog.EndOfLifeDialog
import dev.beefers.vendetta.manager.ui.widgets.dialog.StoragePermissionsDialog
import dev.beefers.vendetta.manager.ui.widgets.home.CommitList
import dev.beefers.vendetta.manager.ui.widgets.updater.UpdateDialog
import dev.beefers.vendetta.manager.utils.Constants
import dev.beefers.vendetta.manager.utils.DiscordVersion
import dev.beefers.vendetta.manager.utils.navigate
import org.koin.compose.koinInject
class HomeScreen : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val prefs: PreferenceManager = koinInject()
val viewModel: HomeViewModel = koinScreenModel()
val currentVersion = remember {
DiscordVersion.fromVersionCode(viewModel.installManager.current?.longVersionCode.toString())
}
val latestVersion =
remember(prefs.discordVersion, viewModel.discordVersions, prefs.channel) {
when {
prefs.discordVersion.isBlank() -> {
val ver = viewModel.discordVersions?.get(prefs.channel)
if (ver == null) return@remember null
if (ver.isSupported()) ver
else DiscordVersion.fromVersionCode(ver.type.maxVersionCode.toString())
}
else -> DiscordVersion.fromVersionCode(prefs.discordVersion)
}
}
// == Dialogs == //
StoragePermissionsDialog()
if (viewModel.showEolDialog)
EndOfLifeDialog { viewModel.showEolDialog = false }
if (
viewModel.showUpdateDialog &&
viewModel.release != null &&
!BuildConfig.DEBUG
) {
UpdateDialog(
release = viewModel.release!!,
isUpdating = viewModel.isUpdating,
onDismiss = { viewModel.showUpdateDialog = false },
onConfirm = {
viewModel.downloadAndInstallUpdate()
}
)
}
// == Screen == //
Scaffold(
topBar = { TitleBar() },
) { pv ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.padding(pv)
.padding(16.dp)
.fillMaxWidth()
) {
AppIcon(
customIcon = prefs.patchIcon,
releaseChannel = prefs.channel,
modifier = Modifier.size(60.dp)
)
Text(
text = prefs.appName,
style = MaterialTheme.typography.titleLarge
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
AnimatedVisibility(visible = currentVersion != null) {
Text(
text = stringResource(
R.string.version_current,
currentVersion.toString()
),
style = MaterialTheme.typography.labelLarge,
color = LocalContentColor.current.copy(alpha = 0.5f),
textAlign = TextAlign.Center
)
}
val latestLabel =
if (prefs.discordVersion.isNotBlank()) R.string.version_target else R.string.version_latest
AnimatedVisibility(visible = latestVersion != null) {
Text(
text = stringResource(latestLabel, latestVersion.toString()),
style = MaterialTheme.typography.labelLarge,
color = LocalContentColor.current.copy(alpha = 0.5f),
textAlign = TextAlign.Center
)
}
}
Button(
onClick = {
navigator.navigate(InstallerScreen(latestVersion!!))
},
enabled = latestVersion != null && (prefs.allowDowngrade || latestVersion >= (currentVersion ?: Constants.DUMMY_VERSION)),
modifier = Modifier.fillMaxWidth()
) {
val label = when {
latestVersion == null -> R.string.msg_loading
currentVersion == null -> R.string.action_install
currentVersion == latestVersion -> R.string.action_reinstall
latestVersion > currentVersion -> R.string.action_update
else -> if (prefs.allowDowngrade) R.string.msg_downgrade else R.string.msg_downgrade_disallowed
}
Text(
text = stringResource(label),
textAlign = TextAlign.Center,
maxLines = 1,
modifier = Modifier
.basicMarquee()
.fillMaxWidth()
)
}
AnimatedVisibility(visible = viewModel.installManager.current != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.clip(RoundedCornerShape(16.dp))
) {
SegmentedButton(
icon = Icons.AutoMirrored.Filled.OpenInNew,
text = stringResource(R.string.action_launch),
onClick = { viewModel.launchVendetta() }
)
SegmentedButton(
icon = Icons.Filled.Info,
text = stringResource(R.string.action_info),
onClick = { viewModel.launchVendettaInfo() }
)
SegmentedButton(
icon = Icons.Filled.Delete,
text = stringResource(R.string.action_uninstall),
onClick = { viewModel.uninstallVendetta() }
)
}
}
ElevatedCard(
modifier = Modifier
.fillMaxSize()
) {
CommitList(
commits = viewModel.commits.collectAsLazyPagingItems()
)
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun TitleBar() {
TopAppBar(
title = { Text(stringResource(R.string.title_home)) },
actions = { Actions() }
)
}
@Composable
private fun Actions() {
val viewModel: HomeViewModel = koinScreenModel()
val navigator = LocalNavigator.currentOrThrow
IconButton(onClick = { viewModel.getDiscordVersions() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.action_reload)
)
}
IconButton(onClick = { navigator.navigate(SettingsScreen()) }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(R.string.action_open_about)
)
}
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/screen/installer/InstallerScreen.kt
================================================
package dev.beefers.vendetta.manager.ui.screen.installer
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
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.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.automirrored.outlined.Article
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.core.screen.ScreenKey
import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
import dev.beefers.vendetta.manager.installer.step.StepStatus
import dev.beefers.vendetta.manager.ui.viewmodel.installer.InstallerViewModel
import dev.beefers.vendetta.manager.ui.widgets.dialog.BackWarningDialog
import dev.beefers.vendetta.manager.ui.widgets.dialog.DownloadFailedDialog
import dev.beefers.vendetta.manager.ui.widgets.installer.StepGroupCard
import dev.beefers.vendetta.manager.utils.DiscordVersion
import okhttp3.internal.toImmutableList
import org.koin.compose.koinInject
import org.koin.core.parameter.parametersOf
import java.util.UUID
class InstallerScreen(
val version: DiscordVersion
) : Screen {
override val key: ScreenKey = "Installer-${UUID.randomUUID()}"
@Composable
override fun Content() {
val nav = LocalNavigator.currentOrThrow
val activity = LocalContext.current as? ComponentActivity
val viewModel: InstallerViewModel = koinScreenModel {
parametersOf(version)
}
LaunchedEffect(viewModel.runner.currentStep) {
viewModel.expandGroup(viewModel.runner.currentStep?.group)
}
// Listen for error messages from InstallService
val intentListener: (Intent) -> Unit = remember {
{
val msg = it.getStringExtra("vendetta.extras.EXTRA_MESSAGE")
if (msg?.isNotBlank() == true) viewModel.logError(msg)
}
}
DisposableEffect(Unit) {
activity?.addOnNewIntentListener(intentListener)
onDispose {
activity?.removeOnNewIntentListener(intentListener)
}
}
BackHandler(
enabled = !viewModel.runner.completed
) {
viewModel.openBackDialog()
}
if(viewModel.backDialogOpened) {
BackWarningDialog(
onExitClick = {
viewModel.closeBackDialog()
viewModel.cancelInstall()
nav.pop()
},
onClose = { viewModel.closeBackDialog() }
)
}
if(viewModel.runner.downloadErrored) {
DownloadFailedDialog(
onTryAgainClick = {
viewModel.dismissDownloadFailedDialog()
viewModel.cancelInstall()
nav.replace(InstallerScreen(version))
},
onDismiss = {
viewModel.dismissDownloadFailedDialog()
}
)
}
Scaffold(
topBar = {
TitleBar(
viewModel = viewModel,
onBackClick = {
if(!viewModel.runner.completed)
viewModel.openBackDialog()
else
nav.pop()
}
)
}
) {
Column(
modifier = Modifier
.padding(it)
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
for ((group, steps) in viewModel.groupedSteps) {
key(group) {
StepGroupCard(
name = stringResource(group.nameRes),
isCurrent = viewModel.expandedGroup == group,
onClick = { viewModel.expandGroup(group) },
steps = steps,
)
}
}
if (viewModel.runner.completed) {
Spacer(modifier = Modifier.height(16.dp))
// Show launch only if success
val installSuccessful = viewModel.runner.currentStep?.status == StepStatus.SUCCESSFUL
if (installSuccessful) {
Button(
onClick = { viewModel.launchVendetta() },
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.action_launch))
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
) {
FilledTonalButton(
onClick = { viewModel.shareLogs(activity!!) },
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.action_share_logs))
}
FilledTonalButton(
onClick = { viewModel.clearCache() },
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.action_clear_cache))
}
}
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun TitleBar(
onBackClick: () -> Unit,
viewModel: InstallerViewModel
) {
val prefs: PreferenceManager = koinInject()
val nav = LocalNavigator.currentOrThrow
TopAppBar(
title = { Text(stringResource(R.string.title_installer)) },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back)
)
}
},
actions = {
if (prefs.isDeveloper && viewModel.runner.completed) {
IconButton(onClick = { nav.push(LogViewerScreen(viewModel.runner.logger.logs.toImmutableList())) }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Article,
contentDescription = stringResource(R.string.action_view_logs)
)
}
}
}
)
}
}
================================================
FILE: app/src/main/java/dev/beefers/vendetta/manager/ui/screen/installer/LogViewerScreen.kt
================================================
package dev.beefers.vendetta.manager.ui.screen.installer
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.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.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import dev.beefers.vendetta.manager.R
import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
import dev.beefers.vendetta.manager.installer.util.LogEntry
import dev.beefers.vendetta.manager.ui.viewmodel.installer.LogViewerViewModel
import dev.beefers.vendetta.manager.ui.widgets.installer.LogLine
import dev.beefers.vendetta.manager.utils.DimenUtils
import dev.beefers.vendetta.manager.utils.rememberFileSaveLauncher
import dev.beefers.vendetta.manager.utils.thenIf
import org.koin.compose.koinInject
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class)
class LogViewerScreen(
val logs: List<LogEntry>
) : Screen {
@Composable
override fun Content() {
val prefs: PreferenceManager = koinInject()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val viewModel: LogViewerViewModel = koinScreenModel {
parametersOf(logs)
}
Scaffold(
topBar = { Toolbar(scrollBehavior, viewModel) },
contentWindowInsets = WindowInsets(0, 0, 0, 0),
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
) { pv ->
LazyColumn(
contentPadding = PaddingValues(bottom = DimenUtils.navBarPadding),
modifier = Modifier
.padding(pv)
.thenIf(!prefs.logsLineWrap) {
horizontalScroll(rememberScrollState())
}
) {
itemsIndexed(viewModel.logs) { i, log ->
LogLine(
log = log,
alternateBackground = i % 2 == 0 && prefs.logsAlternateBackground,
wrapText = prefs.logsLineWrap,
logPadding = viewModel.maxLogLength,
onLongClick = { viewModel.copyLog(log) }
)
}
}
}
}
@Composable
private fun Toolbar(
scrollBehavior: TopAppBarScrollBehavior,
viewModel: LogViewerViewModel
) {
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val saveFile = rememberFileSaveLauncher(content = viewModel.logsString)
TopAppBar(
title = { Text(stringResource(R.string.title_logs)) },
navigationIcon = {
IconButton(onClick = { navigator.pop() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back)
)
}
},
actions = {
var showDropdown by remember {
mutableStateOf(false)
}
IconButton(onClick = { saveFile.launch("VD-Manager-${System.currentTimeMillis()}.log") }) {
Icon(
imageVector = Icons.Outlined.Save,
contentDescription = stringResource(R.string.action_save_logs)
)
}
IconButton(onClick = { viewModel.shareLogs(context) }) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = stringResource(R.string.action_share_logs)
)
}
IconButton(onClick = { showDropdown = true }) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = stringResource(R.string.action_more_options)
)
}
Dropdown(
viewModel,
expanded = showDropdown,
onDismiss = { showDropdown = false }
)
},
scrollBehavior = scrollBehavior
)
}
@Composable
fun Dropdown(
viewModel: LogViewerViewModel,
expanded: Boolean,
onDismiss: () -> Unit
) {
val prefs: PreferenceManager = koinInject()
Box {
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismiss,
offset = DpOffset(
10.dp, 26.dp
)
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.settings_logs_line_wrap)) },
onClick = { prefs.logsLineWrap = !prefs.logsLineWrap },
trailingIcon = {
Checkbox(
checked = prefs.logsLineWrap,
onCheckedChange = { prefs.logsLineWrap = it }
)
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.settings_logs_alternate_lines)) },
gitextract_m7wwd48o/ ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── build-debug.yml │ └── build-release.yml ├── .gitignore ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── libs/ │ │ ├── convert-lspatch.sh │ │ └── lspatch.aar │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── dev/ │ │ └── beefers/ │ │ └── vendetta/ │ │ └── manager/ │ │ ├── ManagerApplication.kt │ │ ├── di/ │ │ │ ├── HttpModule.kt │ │ │ ├── ManagerModule.kt │ │ │ ├── RepositoryModule.kt │ │ │ └── ViewModelModule.kt │ │ ├── domain/ │ │ │ ├── manager/ │ │ │ │ ├── DownloadManager.kt │ │ │ │ ├── InstallManager.kt │ │ │ │ ├── PreferenceManager.kt │ │ │ │ └── base/ │ │ │ │ └── BasePreferenceManager.kt │ │ │ ├── receiver/ │ │ │ │ └── InstallReceiver.kt │ │ │ └── repository/ │ │ │ └── RestRepository.kt │ │ ├── installer/ │ │ │ ├── Installer.kt │ │ │ ├── session/ │ │ │ │ ├── SessionInstaller.kt │ │ │ │ └── SessionInstallerService.kt │ │ │ ├── shizuku/ │ │ │ │ ├── ShizukuInstaller.kt │ │ │ │ └── ShizukuPermissions.kt │ │ │ ├── step/ │ │ │ │ ├── Step.kt │ │ │ │ ├── StepGroup.kt │ │ │ │ ├── StepRunner.kt │ │ │ │ ├── StepStatus.kt │ │ │ │ ├── download/ │ │ │ │ │ ├── DownloadBaseStep.kt │ │ │ │ │ ├── DownloadLangStep.kt │ │ │ │ │ ├── DownloadLibsStep.kt │ │ │ │ │ ├── DownloadResourcesStep.kt │ │ │ │ │ ├── DownloadVendettaStep.kt │ │ │ │ │ └── base/ │ │ │ │ │ └── DownloadStep.kt │ │ │ │ ├── installing/ │ │ │ │ │ └── InstallStep.kt │ │ │ │ └── patching/ │ │ │ │ ├── AddVendettaStep.kt │ │ │ │ ├── PatchManifestsStep.kt │ │ │ │ ├── PresignApksStep.kt │ │ │ │ └── ReplaceIconStep.kt │ │ │ └── util/ │ │ │ ├── ArscUtil.kt │ │ │ ├── AxmlUtil.kt │ │ │ ├── LogEntry.kt │ │ │ ├── Logger.kt │ │ │ ├── ManifestPatcher.kt │ │ │ ├── Patcher.kt │ │ │ └── Signer.kt │ │ ├── network/ │ │ │ ├── dto/ │ │ │ │ ├── Commit.kt │ │ │ │ ├── Index.kt │ │ │ │ ├── Release.kt │ │ │ │ └── User.kt │ │ │ ├── service/ │ │ │ │ ├── HttpService.kt │ │ │ │ └── RestService.kt │ │ │ └── utils/ │ │ │ ├── ApiResponse.kt │ │ │ └── CommitsPagingSource.kt │ │ ├── ui/ │ │ │ ├── activity/ │ │ │ │ └── MainActivity.kt │ │ │ ├── components/ │ │ │ │ ├── Label.kt │ │ │ │ ├── NavBarSpacer.kt │ │ │ │ ├── RadioController.kt │ │ │ │ ├── SegmentedButton.kt │ │ │ │ ├── ThinDivider.kt │ │ │ │ └── settings/ │ │ │ │ ├── SettingsButton.kt │ │ │ │ ├── SettingsCategory.kt │ │ │ │ ├── SettingsChoiceDialog.kt │ │ │ │ ├── SettingsHeader.kt │ │ │ │ ├── SettingsItem.kt │ │ │ │ ├── SettingsItemChoice.kt │ │ │ │ ├── SettingsSwitch.kt │ │ │ │ └── SettingsTextField.kt │ │ │ ├── screen/ │ │ │ │ ├── about/ │ │ │ │ │ └── AboutScreen.kt │ │ │ │ ├── home/ │ │ │ │ │ └── HomeScreen.kt │ │ │ │ ├── installer/ │ │ │ │ │ ├── InstallerScreen.kt │ │ │ │ │ └── LogViewerScreen.kt │ │ │ │ ├── libraries/ │ │ │ │ │ └── LibrariesScreen.kt │ │ │ │ └── settings/ │ │ │ │ ├── AdvancedSettings.kt │ │ │ │ ├── AppearanceSettings.kt │ │ │ │ ├── CustomizationSettings.kt │ │ │ │ ├── DeveloperSettings.kt │ │ │ │ └── SettingsScreen.kt │ │ │ ├── theme/ │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ ├── viewmodel/ │ │ │ │ ├── home/ │ │ │ │ │ └── HomeViewModel.kt │ │ │ │ ├── installer/ │ │ │ │ │ ├── InstallerViewModel.kt │ │ │ │ │ └── LogViewerViewModel.kt │ │ │ │ ├── libraries/ │ │ │ │ │ └── LibrariesViewModel.kt │ │ │ │ └── settings/ │ │ │ │ └── AdvancedSettingsViewModel.kt │ │ │ └── widgets/ │ │ │ ├── AppIcon.kt │ │ │ ├── about/ │ │ │ │ ├── LinkItem.kt │ │ │ │ ├── ListItem.kt │ │ │ │ └── UserEntry.kt │ │ │ ├── dialog/ │ │ │ │ ├── BackWarningDialog.kt │ │ │ │ ├── DownloadFailedDialog.kt │ │ │ │ ├── EndOfLifeDialog.kt │ │ │ │ └── StoragePermissionsDialog.kt │ │ │ ├── home/ │ │ │ │ ├── Commit.kt │ │ │ │ └── CommitList.kt │ │ │ ├── installer/ │ │ │ │ ├── LogLine.kt │ │ │ │ ├── StepGroupCard.kt │ │ │ │ ├── StepIcon.kt │ │ │ │ └── StepRow.kt │ │ │ ├── libraries/ │ │ │ │ ├── LibraryItem.kt │ │ │ │ └── LicenseBottomSheet.kt │ │ │ ├── settings/ │ │ │ │ ├── ThemePicker.kt │ │ │ │ ├── ThemePickerOption.kt │ │ │ │ └── ThemePreview.kt │ │ │ └── updater/ │ │ │ └── UpdateDialog.kt │ │ ├── updatechecker/ │ │ │ ├── reciever/ │ │ │ │ └── UpdateBroadcastReceiver.kt │ │ │ └── worker/ │ │ │ └── UpdateWorker.kt │ │ └── utils/ │ │ ├── Constants.kt │ │ ├── Context.kt │ │ ├── DimenUtils.kt │ │ ├── LazyUtils.kt │ │ ├── ModifierUtils.kt │ │ ├── NavUtils.kt │ │ ├── Utils.kt │ │ └── VersionUtils.kt │ └── res/ │ ├── drawable/ │ │ ├── bunny_logo.xml │ │ ├── ic_discord.xml │ │ ├── ic_discord_icon.xml │ │ ├── ic_github.xml │ │ ├── ic_update.xml │ │ ├── ts_avatars.xml │ │ ├── ts_bg.xml │ │ ├── ts_content.xml │ │ ├── ts_content_50.xml │ │ ├── ts_icon.xml │ │ ├── ts_outline.xml │ │ ├── ts_primary.xml │ │ ├── ts_status.xml │ │ ├── ts_surface_l1.xml │ │ └── ts_surface_l2.xml │ ├── drawable-v26/ │ │ └── ic_launcher.xml │ ├── values/ │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ ├── values-af-rZA/ │ │ └── strings.xml │ ├── values-ar-rSA/ │ │ └── strings.xml │ ├── values-ca-rES/ │ │ └── strings.xml │ ├── values-cs-rCZ/ │ │ └── strings.xml │ ├── values-da-rDK/ │ │ └── strings.xml │ ├── values-de-rDE/ │ │ └── strings.xml │ ├── values-el-rGR/ │ │ └── strings.xml │ ├── values-en-rUS/ │ │ └── strings.xml │ ├── values-es-rES/ │ │ └── strings.xml │ ├── values-fi-rFI/ │ │ └── strings.xml │ ├── values-fil-rPH/ │ │ └── strings.xml │ ├── values-fr-rFR/ │ │ └── strings.xml │ ├── values-hu-rHU/ │ │ └── strings.xml │ ├── values-in-rID/ │ │ └── strings.xml │ ├── values-it-rIT/ │ │ └── strings.xml │ ├── values-iw-rIL/ │ │ └── strings.xml │ ├── values-ja-rJP/ │ │ └── strings.xml │ ├── values-ko-rKR/ │ │ └── strings.xml │ ├── values-nl-rNL/ │ │ └── strings.xml │ ├── values-no-rNO/ │ │ └── strings.xml │ ├── values-or-rIN/ │ │ └── strings.xml │ ├── values-pl-rPL/ │ │ └── strings.xml │ ├── values-pt-rBR/ │ │ └── strings.xml │ ├── values-pt-rPT/ │ │ └── strings.xml │ ├── values-ro-rRO/ │ │ └── strings.xml │ ├── values-ru-rRU/ │ │ └── strings.xml │ ├── values-sr-rSP/ │ │ └── strings.xml │ ├── values-sv-rSE/ │ │ └── strings.xml │ ├── values-tr-rTR/ │ │ └── strings.xml │ ├── values-uk-rUA/ │ │ └── strings.xml │ ├── values-vi-rVN/ │ │ └── strings.xml │ ├── values-zh-rCN/ │ │ └── strings.xml │ ├── values-zh-rTW/ │ │ └── strings.xml │ └── xml/ │ ├── backup_rules.xml │ ├── data_extraction_rules.xml │ ├── locales_config.xml │ └── provider_paths.xml ├── build.gradle.kts ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts
Condensed preview — 183 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (750K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 15,
"preview": "github: wingio\n"
},
{
"path": ".github/workflows/build-debug.yml",
"chars": 828,
"preview": "name: Build debug APK\n\non:\n push:\n branches:\n - '*'\n paths-ignore:\n - '**.md'\n - '.idea/*'\n -"
},
{
"path": ".github/workflows/build-release.yml",
"chars": 1956,
"preview": "name: Build Release\n\non:\n workflow_dispatch:\n inputs:\n versionName:\n required: true\n description:"
},
{
"path": ".gitignore",
"chars": 1444,
"preview": "# Built application files\n*.apk\n*.ap_\n*.aab\n\n# Files for the ART/Dalvik VM\n*.dex\n\n# Java class files\n*.class\n\n# Generate"
},
{
"path": "LICENSE",
"chars": 10297,
"preview": "Open Software License (\"OSL\") v. 3.0\n\nThis Open Software License (the \"License\") applies to any original work of\nauthors"
},
{
"path": "README.md",
"chars": 3095,
"preview": "> [!IMPORTANT]\n> ## Project Archival\n> \n> **This project is no longer actively maintained and this repository has been a"
},
{
"path": "app/.gitignore",
"chars": 15,
"preview": "/build\n/release"
},
{
"path": "app/build.gradle.kts",
"chars": 4476,
"preview": "import java.io.ByteArrayOutputStream\n\nplugins {\n alias(libs.plugins.aboutlibraries)\n alias(libs.plugins.android.ap"
},
{
"path": "app/libs/convert-lspatch.sh",
"chars": 1107,
"preview": "# Download LSPatch jar and convert it to an aar\n# Jars cannot be used as a dependency without breaking R8 optimization\n\n"
},
{
"path": "app/proguard-rules.pro",
"chars": 2945,
"preview": "# Keep `Companion` object fields of serializable classes.\n# This avoids serializer lookup through `getDeclaredClasses` a"
},
{
"path": "app/src/main/AndroidManifest.xml",
"chars": 3967,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n xmlns:to"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ManagerApplication.kt",
"chars": 2111,
"preview": "package dev.beefers.vendetta.manager\n\nimport android.app.Application\nimport android.app.NotificationChannel\nimport andro"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/di/HttpModule.kt",
"chars": 841,
"preview": "package dev.beefers.vendetta.manager.di\n\nimport dev.beefers.vendetta.manager.network.service.HttpService\nimport dev.beef"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/di/ManagerModule.kt",
"chars": 439,
"preview": "package dev.beefers.vendetta.manager.di\n\nimport dev.beefers.vendetta.manager.domain.manager.DownloadManager\nimport dev.b"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/di/RepositoryModule.kt",
"chars": 243,
"preview": "package dev.beefers.vendetta.manager.di\n\nimport dev.beefers.vendetta.manager.domain.repository.RestRepository\nimport org"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/di/ViewModelModule.kt",
"chars": 711,
"preview": "package dev.beefers.vendetta.manager.di\n\nimport dev.beefers.vendetta.manager.ui.viewmodel.home.HomeViewModel\nimport dev."
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/domain/manager/DownloadManager.kt",
"chars": 5936,
"preview": "package dev.beefers.vendetta.manager.domain.manager\n\nimport android.app.DownloadManager\nimport android.content.Context\ni"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/domain/manager/InstallManager.kt",
"chars": 2186,
"preview": "package dev.beefers.vendetta.manager.domain.manager\n\nimport android.annotation.SuppressLint\nimport android.app.PendingIn"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/domain/manager/PreferenceManager.kt",
"chars": 3388,
"preview": "package dev.beefers.vendetta.manager.domain.manager\n\nimport android.content.Context\nimport android.os.Build\nimport andro"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/domain/manager/base/BasePreferenceManager.kt",
"chars": 4157,
"preview": "package dev.beefers.vendetta.manager.domain.manager.base\n\nimport android.content.SharedPreferences\nimport androidx.compo"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/domain/receiver/InstallReceiver.kt",
"chars": 632,
"preview": "package dev.beefers.vendetta.manager.domain.receiver\n\nimport android.annotation.SuppressLint\nimport android.content.Broa"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/domain/repository/RestRepository.kt",
"chars": 867,
"preview": "package dev.beefers.vendetta.manager.domain.repository\n\nimport dev.beefers.vendetta.manager.network.service.RestService\n"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/Installer.kt",
"chars": 164,
"preview": "package dev.beefers.vendetta.manager.installer\n\nimport java.io.File\n\ninterface Installer {\n suspend fun installApks(s"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/session/SessionInstaller.kt",
"chars": 1897,
"preview": "package dev.beefers.vendetta.manager.installer.session\n\nimport android.annotation.SuppressLint\nimport android.app.Pendin"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/session/SessionInstallerService.kt",
"chars": 2630,
"preview": "package dev.beefers.vendetta.manager.installer.session\n\nimport android.app.Service\nimport android.content.Intent\nimport "
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/shizuku/ShizukuInstaller.kt",
"chars": 2223,
"preview": "package dev.beefers.vendetta.manager.installer.shizuku\n\nimport android.content.Context\nimport dev.beefers.vendetta.manag"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/shizuku/ShizukuPermissions.kt",
"chars": 1722,
"preview": "package dev.beefers.vendetta.manager.installer.shizuku\n\nimport android.content.pm.PackageManager\nimport kotlinx.coroutin"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/step/Step.kt",
"chars": 2061,
"preview": "package dev.beefers.vendetta.manager.installer.step\n\nimport androidx.annotation.StringRes\nimport androidx.compose.runtim"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepGroup.kt",
"chars": 580,
"preview": "package dev.beefers.vendetta.manager.installer.step\n\nimport androidx.annotation.StringRes\nimport dev.beefers.vendetta.ma"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepRunner.kt",
"chars": 6848,
"preview": "package dev.beefers.vendetta.manager.installer.step\n\nimport android.content.Context\nimport android.os.Build\nimport andro"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepStatus.kt",
"chars": 323,
"preview": "package dev.beefers.vendetta.manager.installer.step\n\nenum class StepStatus {\n /**\n * Currently in progress\n *"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadBaseStep.kt",
"chars": 650,
"preview": "package dev.beefers.vendetta.manager.installer.step.download\n\nimport androidx.compose.runtime.Stable\nimport dev.beefers."
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadLangStep.kt",
"chars": 746,
"preview": "package dev.beefers.vendetta.manager.installer.step.download\n\nimport androidx.compose.runtime.Stable\nimport dev.beefers."
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadLibsStep.kt",
"chars": 938,
"preview": "package dev.beefers.vendetta.manager.installer.step.download\n\nimport android.os.Build\nimport androidx.compose.runtime.St"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadResourcesStep.kt",
"chars": 717,
"preview": "package dev.beefers.vendetta.manager.installer.step.download\n\nimport androidx.compose.runtime.Stable\nimport dev.beefers."
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadVendettaStep.kt",
"chars": 702,
"preview": "package dev.beefers.vendetta.manager.installer.step.download\n\nimport androidx.compose.runtime.Stable\nimport dev.beefers."
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/base/DownloadStep.kt",
"chars": 4473,
"preview": "package dev.beefers.vendetta.manager.installer.step.download.base\n\nimport android.content.Context\nimport androidx.compos"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/step/installing/InstallStep.kt",
"chars": 1741,
"preview": "package dev.beefers.vendetta.manager.installer.step.installing\n\nimport android.content.Context\nimport dev.beefers.vendet"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/AddVendettaStep.kt",
"chars": 1378,
"preview": "package dev.beefers.vendetta.manager.installer.step.patching\n\nimport dev.beefers.vendetta.manager.R\nimport dev.beefers.v"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/PatchManifestsStep.kt",
"chars": 3066,
"preview": "package dev.beefers.vendetta.manager.installer.step.patching\n\nimport com.github.diamondminer88.zip.ZipReader\nimport com."
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/PresignApksStep.kt",
"chars": 2745,
"preview": "package dev.beefers.vendetta.manager.installer.step.patching\n\nimport android.os.Build\nimport com.github.diamondminer88.z"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/ReplaceIconStep.kt",
"chars": 3104,
"preview": "package dev.beefers.vendetta.manager.installer.step.patching\n\nimport android.content.Context\nimport androidx.compose.ui."
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/util/ArscUtil.kt",
"chars": 5926,
"preview": "// https://github.com/Aliucord/Manager/blob/main/app/src/main/kotlin/com/aliucord/manager/installer/util/ArscUtil.kt\npac"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/util/AxmlUtil.kt",
"chars": 8682,
"preview": "// https://github.com/Aliucord/Manager/blob/main/app/src/main/kotlin/com/aliucord/manager/installer/util/AxmlUtil.kt\npac"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/util/LogEntry.kt",
"chars": 1519,
"preview": "package dev.beefers.vendetta.manager.installer.util\n\nimport android.annotation.SuppressLint\nimport android.os.Parcel\nimp"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/util/Logger.kt",
"chars": 1571,
"preview": "package dev.beefers.vendetta.manager.installer.util\n\nimport android.util.Log\nimport androidx.compose.runtime.Stable\nimpo"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/util/ManifestPatcher.kt",
"chars": 10909,
"preview": "package dev.beefers.vendetta.manager.installer.util\n\nimport android.Manifest\nimport pxb.android.axml.AxmlReader\nimport p"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/util/Patcher.kt",
"chars": 907,
"preview": "package dev.beefers.vendetta.manager.installer.util\n\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.wit"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/installer/util/Signer.kt",
"chars": 4446,
"preview": "package dev.beefers.vendetta.manager.installer.util\n\nimport android.content.Context\nimport com.android.apksig.ApkSigner\n"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/network/dto/Commit.kt",
"chars": 519,
"preview": "package dev.beefers.vendetta.manager.network.dto\n\nimport kotlinx.datetime.Instant\nimport kotlinx.serialization.SerialNam"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/network/dto/Index.kt",
"chars": 287,
"preview": "package dev.beefers.vendetta.manager.network.dto\n\nimport kotlinx.serialization.Serializable\n\n@Serializable\ndata class In"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/network/dto/Release.kt",
"chars": 265,
"preview": "package dev.beefers.vendetta.manager.network.dto\n\nimport kotlinx.serialization.SerialName\nimport kotlinx.serialization.S"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/network/dto/User.kt",
"chars": 261,
"preview": "package dev.beefers.vendetta.manager.network.dto\n\nimport kotlinx.serialization.SerialName\nimport kotlinx.serialization.S"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/network/service/HttpService.kt",
"chars": 1431,
"preview": "package dev.beefers.vendetta.manager.network.service\n\nimport dev.beefers.vendetta.manager.network.utils.ApiError\nimport "
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/network/service/RestService.kt",
"chars": 1185,
"preview": "package dev.beefers.vendetta.manager.network.service\n\nimport dev.beefers.vendetta.manager.domain.manager.PreferenceManag"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/network/utils/ApiResponse.kt",
"chars": 1453,
"preview": "package dev.beefers.vendetta.manager.network.utils\n\nimport io.ktor.http.HttpStatusCode\n\nsealed interface ApiResponse<D> "
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/network/utils/CommitsPagingSource.kt",
"chars": 1119,
"preview": "package dev.beefers.vendetta.manager.network.utils\n\nimport androidx.paging.PagingSource\nimport androidx.paging.PagingSta"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/activity/MainActivity.kt",
"chars": 2418,
"preview": "package dev.beefers.vendetta.manager.ui.activity\n\nimport android.Manifest\nimport android.content.pm.PackageManager\nimpor"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/components/Label.kt",
"chars": 2078,
"preview": "package dev.beefers.vendetta.manager.ui.components\n\nimport androidx.compose.foundation.background\nimport androidx.compos"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/components/NavBarSpacer.kt",
"chars": 396,
"preview": "package dev.beefers.vendetta.manager.ui.components\n\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.com"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/components/RadioController.kt",
"chars": 1673,
"preview": "package dev.beefers.vendetta.manager.ui.components\n\nimport androidx.compose.foundation.clickable\nimport androidx.compose"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/components/SegmentedButton.kt",
"chars": 2105,
"preview": "package dev.beefers.vendetta.manager.ui.components\n\nimport androidx.compose.foundation.background\nimport androidx.compos"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/components/ThinDivider.kt",
"chars": 366,
"preview": "package dev.beefers.vendetta.manager.ui.components\n\nimport androidx.compose.material3.HorizontalDivider\nimport androidx."
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsButton.kt",
"chars": 819,
"preview": "package dev.beefers.vendetta.manager.ui.components.settings\n\nimport androidx.compose.foundation.layout.Box\nimport androi"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsCategory.kt",
"chars": 1026,
"preview": "package dev.beefers.vendetta.manager.ui.components.settings\n\nimport androidx.compose.foundation.clickable\nimport android"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsChoiceDialog.kt",
"chars": 1891,
"preview": "package dev.beefers.vendetta.manager.ui.components.settings\n\nimport androidx.compose.animation.AnimatedVisibility\nimport"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsHeader.kt",
"chars": 521,
"preview": "package dev.beefers.vendetta.manager.ui.components.settings\n\nimport androidx.compose.foundation.layout.padding\nimport an"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsItem.kt",
"chars": 2038,
"preview": "package dev.beefers.vendetta.manager.ui.components.settings\n\nimport androidx.compose.foundation.layout.Arrangement\nimpor"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsItemChoice.kt",
"chars": 1568,
"preview": "package dev.beefers.vendetta.manager.ui.components.settings\n\nimport androidx.compose.foundation.clickable\nimport android"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsSwitch.kt",
"chars": 857,
"preview": "package dev.beefers.vendetta.manager.ui.components.settings\n\nimport androidx.compose.foundation.clickable\nimport android"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsTextField.kt",
"chars": 925,
"preview": "package dev.beefers.vendetta.manager.ui.components.settings\n\nimport androidx.compose.foundation.layout.*\nimport androidx"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/screen/about/AboutScreen.kt",
"chars": 11687,
"preview": "package dev.beefers.vendetta.manager.ui.screen.about\n\nimport androidx.compose.foundation.Image\nimport androidx.compose.f"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/screen/home/HomeScreen.kt",
"chars": 10213,
"preview": "package dev.beefers.vendetta.manager.ui.screen.home\n\nimport androidx.compose.animation.AnimatedVisibility\nimport android"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/screen/installer/InstallerScreen.kt",
"chars": 8247,
"preview": "package dev.beefers.vendetta.manager.ui.screen.installer\n\nimport android.content.Intent\nimport androidx.activity.Compone"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/screen/installer/LogViewerScreen.kt",
"chars": 7560,
"preview": "package dev.beefers.vendetta.manager.ui.screen.installer\n\nimport androidx.compose.foundation.horizontalScroll\nimport and"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/screen/libraries/LibrariesScreen.kt",
"chars": 3162,
"preview": "package dev.beefers.vendetta.manager.ui.screen.libraries\n\nimport androidx.compose.foundation.layout.Column\nimport androi"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/screen/settings/AdvancedSettings.kt",
"chars": 5287,
"preview": "package dev.beefers.vendetta.manager.ui.screen.settings\n\nimport androidx.compose.foundation.layout.Column\nimport android"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/screen/settings/AppearanceSettings.kt",
"chars": 3647,
"preview": "package dev.beefers.vendetta.manager.ui.screen.settings\n\nimport android.os.Build\nimport androidx.compose.foundation.layo"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/screen/settings/CustomizationSettings.kt",
"chars": 4139,
"preview": "package dev.beefers.vendetta.manager.ui.screen.settings\n\nimport androidx.compose.foundation.layout.Column\nimport android"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/screen/settings/DeveloperSettings.kt",
"chars": 6281,
"preview": "package dev.beefers.vendetta.manager.ui.screen.settings\n\nimport androidx.compose.foundation.layout.Column\nimport android"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/screen/settings/SettingsScreen.kt",
"chars": 5406,
"preview": "package dev.beefers.vendetta.manager.ui.screen.settings\n\nimport androidx.compose.foundation.layout.Column\nimport android"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/theme/Theme.kt",
"chars": 1416,
"preview": "package dev.beefers.vendetta.manager.ui.theme\n\nimport android.os.Build\nimport androidx.compose.foundation.isSystemInDark"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/theme/Type.kt",
"chars": 173,
"preview": "package dev.beefers.vendetta.manager.ui.theme\n\nimport androidx.compose.material3.Typography\n\n// Set of Material typograp"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/home/HomeViewModel.kt",
"chars": 5222,
"preview": "package dev.beefers.vendetta.manager.ui.viewmodel.home\n\nimport android.content.Context\nimport android.content.Intent\nimp"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/installer/InstallerViewModel.kt",
"chars": 4143,
"preview": "package dev.beefers.vendetta.manager.ui.viewmodel.installer\n\nimport android.content.Context\nimport android.content.Inten"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/installer/LogViewerViewModel.kt",
"chars": 2022,
"preview": "package dev.beefers.vendetta.manager.ui.viewmodel.installer\n\nimport android.content.Context\nimport android.content.Inten"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/libraries/LibrariesViewModel.kt",
"chars": 366,
"preview": "package dev.beefers.vendetta.manager.ui.viewmodel.libraries\n\nimport android.content.Context\nimport cafe.adriel.voyager.c"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/settings/AdvancedSettingsViewModel.kt",
"chars": 2403,
"preview": "package dev.beefers.vendetta.manager.ui.viewmodel.settings\n\nimport android.content.Context\nimport android.os.Environment"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/AppIcon.kt",
"chars": 1125,
"preview": "package dev.beefers.vendetta.manager.ui.widgets\n\nimport androidx.compose.foundation.Image\nimport androidx.compose.founda"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/about/LinkItem.kt",
"chars": 1833,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.about\n\nimport androidx.annotation.DrawableRes\nimport androidx.annotation"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/about/ListItem.kt",
"chars": 2024,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.about\n\nimport androidx.compose.foundation.clickable\nimport androidx.comp"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/about/UserEntry.kt",
"chars": 2594,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.about\n\nimport androidx.compose.foundation.background\nimport androidx.com"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/dialog/BackWarningDialog.kt",
"chars": 2079,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.dialog\n\nimport androidx.compose.material.icons.Icons\nimport androidx.com"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/dialog/DownloadFailedDialog.kt",
"chars": 2668,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.dialog\n\nimport androidx.compose.foundation.layout.Arrangement\nimport and"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/dialog/EndOfLifeDialog.kt",
"chars": 1688,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.dialog\n\nimport androidx.compose.material.icons.Icons\nimport androidx.com"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/dialog/StoragePermissionsDialog.kt",
"chars": 3062,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.dialog\n\nimport android.Manifest\nimport android.annotation.SuppressLint\ni"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/home/Commit.kt",
"chars": 3214,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.home\n\nimport androidx.compose.foundation.clickable\nimport androidx.compo"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/home/CommitList.kt",
"chars": 3261,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.home\n\nimport androidx.compose.foundation.layout.Arrangement\nimport andro"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/LogLine.kt",
"chars": 4198,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.installer\n\nimport androidx.compose.foundation.ExperimentalFoundationApi\n"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepGroupCard.kt",
"chars": 4336,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.installer\n\nimport androidx.compose.animation.AnimatedVisibility\nimport a"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepIcon.kt",
"chars": 3336,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.installer\n\nimport androidx.compose.foundation.layout.size\nimport android"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepRow.kt",
"chars": 2768,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.installer\n\nimport androidx.compose.animation.core.animateFloatAsState\nim"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/libraries/LibraryItem.kt",
"chars": 4748,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.libraries\n\nimport androidx.compose.foundation.ExperimentalFoundationApi\n"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/libraries/LicenseBottomSheet.kt",
"chars": 2817,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.libraries\n\nimport androidx.compose.foundation.background\nimport androidx"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/settings/ThemePicker.kt",
"chars": 7045,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.settings\n\nimport android.annotation.SuppressLint\nimport androidx.compose"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/settings/ThemePickerOption.kt",
"chars": 3219,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.settings\n\nimport androidx.compose.animation.animateContentSize\nimport an"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/settings/ThemePreview.kt",
"chars": 3593,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.settings\n\nimport androidx.compose.foundation.Image\nimport androidx.compo"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/updater/UpdateDialog.kt",
"chars": 2535,
"preview": "package dev.beefers.vendetta.manager.ui.widgets.updater\n\nimport androidx.compose.foundation.layout.Box\nimport androidx.c"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/updatechecker/reciever/UpdateBroadcastReceiver.kt",
"chars": 1874,
"preview": "package dev.beefers.vendetta.manager.updatechecker.reciever\n\nimport android.app.NotificationManager\nimport android.app.P"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/updatechecker/worker/UpdateWorker.kt",
"chars": 2061,
"preview": "package dev.beefers.vendetta.manager.updatechecker.worker\n\nimport android.content.Context\nimport android.content.Intent\n"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/utils/Constants.kt",
"chars": 1011,
"preview": "package dev.beefers.vendetta.manager.utils\n\nimport android.os.Environment\nimport dev.beefers.vendetta.manager.BuildConfi"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/utils/Context.kt",
"chars": 748,
"preview": "package dev.beefers.vendetta.manager.utils\n\nimport android.content.Context\nimport android.util.TypedValue\nimport android"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/utils/DimenUtils.kt",
"chars": 433,
"preview": "package dev.beefers.vendetta.manager.utils\n\nimport androidx.compose.foundation.layout.WindowInsets\nimport androidx.compo"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/utils/LazyUtils.kt",
"chars": 642,
"preview": "package dev.beefers.vendetta.manager.utils\n\nimport androidx.compose.foundation.lazy.LazyListScope\nimport androidx.compos"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/utils/ModifierUtils.kt",
"chars": 755,
"preview": "package dev.beefers.vendetta.manager.utils\n\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.composed\nimpo"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/utils/NavUtils.kt",
"chars": 277,
"preview": "package dev.beefers.vendetta.manager.utils\n\nimport cafe.adriel.voyager.core.screen.Screen\nimport cafe.adriel.voyager.nav"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/utils/Utils.kt",
"chars": 3598,
"preview": "package dev.beefers.vendetta.manager.utils\n\nimport android.content.ClipData\nimport android.content.ClipboardManager\nimpo"
},
{
"path": "app/src/main/java/dev/beefers/vendetta/manager/utils/VersionUtils.kt",
"chars": 1638,
"preview": "package dev.beefers.vendetta.manager.utils\n\nimport androidx.annotation.StringRes\nimport dev.beefers.vendetta.manager.R\ni"
},
{
"path": "app/src/main/res/drawable/bunny_logo.xml",
"chars": 1359,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" xmlns:aapt=\"http://schemas.android.com/aapt\"\n andr"
},
{
"path": "app/src/main/res/drawable/ic_discord.xml",
"chars": 1898,
"preview": "<vector android:height=\"24dp\" android:viewportHeight=\"512\"\n android:viewportWidth=\"512\" android:width=\"24dp\" xmlns:an"
},
{
"path": "app/src/main/res/drawable/ic_discord_icon.xml",
"chars": 1933,
"preview": "<vector android:height=\"24dp\" android:viewportHeight=\"512\"\n android:viewportWidth=\"512\" android:width=\"24dp\" xmlns:an"
},
{
"path": "app/src/main/res/drawable/ic_github.xml",
"chars": 1299,
"preview": "<vector android:height=\"24dp\" android:viewportHeight=\"1024\"\n android:viewportWidth=\"1024\" android:width=\"24dp\" xmlns:"
},
{
"path": "app/src/main/res/drawable/ic_update.xml",
"chars": 2361,
"preview": "<vector android:height=\"24dp\" android:viewportHeight=\"512\"\n android:viewportWidth=\"512\" android:width=\"24dp\" xmlns:an"
},
{
"path": "app/src/main/res/drawable/ts_avatars.xml",
"chars": 1069,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"305dp\"\n android:height=\"661dp\"\n"
},
{
"path": "app/src/main/res/drawable/ts_bg.xml",
"chars": 650,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"305dp\"\n android:height=\"661dp\"\n"
},
{
"path": "app/src/main/res/drawable/ts_content.xml",
"chars": 4747,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"305dp\"\n android:height=\"661dp\"\n"
},
{
"path": "app/src/main/res/drawable/ts_content_50.xml",
"chars": 2611,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"305dp\"\n android:height=\"661dp\"\n"
},
{
"path": "app/src/main/res/drawable/ts_icon.xml",
"chars": 531,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"305dp\"\n android:height=\"661dp\"\n"
},
{
"path": "app/src/main/res/drawable/ts_outline.xml",
"chars": 1636,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"305dp\"\n android:height=\"661dp\"\n"
},
{
"path": "app/src/main/res/drawable/ts_primary.xml",
"chars": 3663,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"305dp\"\n android:height=\"661dp\"\n"
},
{
"path": "app/src/main/res/drawable/ts_status.xml",
"chars": 1310,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"305dp\"\n android:height=\"661dp\"\n"
},
{
"path": "app/src/main/res/drawable/ts_surface_l1.xml",
"chars": 699,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"305dp\"\n android:height=\"661dp\"\n"
},
{
"path": "app/src/main/res/drawable/ts_surface_l2.xml",
"chars": 855,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"305dp\"\n android:height=\"661dp\"\n"
},
{
"path": "app/src/main/res/drawable-v26/ic_launcher.xml",
"chars": 305,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n <b"
},
{
"path": "app/src/main/res/values/colors.xml",
"chars": 426,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <color name=\"purple_200\">#FFBB86FC</color>\n <color name=\"purpl"
},
{
"path": "app/src/main/res/values/strings.xml",
"chars": 9938,
"preview": "<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTranslation\">\n <string name=\"app_name\""
},
{
"path": "app/src/main/res/values/themes.xml",
"chars": 451,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <style name=\"Theme.Manager\" parent=\"android:Theme.Material.NoActi"
},
{
"path": "app/src/main/res/values-af-rZA/strings.xml",
"chars": 9972,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-ar-rSA/strings.xml",
"chars": 9609,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-ca-rES/strings.xml",
"chars": 10093,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-cs-rCZ/strings.xml",
"chars": 9939,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-da-rDK/strings.xml",
"chars": 9818,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-de-rDE/strings.xml",
"chars": 10313,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-el-rGR/strings.xml",
"chars": 10283,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-en-rUS/strings.xml",
"chars": 9662,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-es-rES/strings.xml",
"chars": 10165,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-fi-rFI/strings.xml",
"chars": 9656,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-fil-rPH/strings.xml",
"chars": 10221,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-fr-rFR/strings.xml",
"chars": 10810,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-hu-rHU/strings.xml",
"chars": 10112,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-in-rID/strings.xml",
"chars": 9732,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-it-rIT/strings.xml",
"chars": 10104,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-iw-rIL/strings.xml",
"chars": 9422,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-ja-rJP/strings.xml",
"chars": 8683,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-ko-rKR/strings.xml",
"chars": 8586,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-nl-rNL/strings.xml",
"chars": 9895,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-no-rNO/strings.xml",
"chars": 9637,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-or-rIN/strings.xml",
"chars": 9656,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-pl-rPL/strings.xml",
"chars": 10297,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-pt-rBR/strings.xml",
"chars": 10197,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-pt-rPT/strings.xml",
"chars": 9617,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-ro-rRO/strings.xml",
"chars": 9963,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-ru-rRU/strings.xml",
"chars": 10303,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-sr-rSP/strings.xml",
"chars": 9656,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-sv-rSE/strings.xml",
"chars": 9926,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-tr-rTR/strings.xml",
"chars": 9995,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-uk-rUA/strings.xml",
"chars": 10011,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-vi-rVN/strings.xml",
"chars": 9917,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-zh-rCN/strings.xml",
"chars": 8081,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/values-zh-rTW/strings.xml",
"chars": 8140,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\" tools:ignore=\"MissingTr"
},
{
"path": "app/src/main/res/xml/backup_rules.xml",
"chars": 478,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!--\n Sample backup rules file; uncomment and customize as necessary.\n See htt"
},
{
"path": "app/src/main/res/xml/data_extraction_rules.xml",
"chars": 551,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!--\n Sample data extraction rules file; uncomment and customize as necessary.\n "
},
{
"path": "app/src/main/res/xml/locales_config.xml",
"chars": 1251,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<locale-config xmlns:android=\"http://schemas.android.com/apk/res/android\">\n <l"
},
{
"path": "app/src/main/res/xml/provider_paths.xml",
"chars": 106,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<paths>\n <files-path name=\"temp_logs\" path=\"logsTmp/\"/>\n</paths>"
},
{
"path": "build.gradle.kts",
"chars": 630,
"preview": "// Top-level build file where you can add configuration options common to all sub-projects/modules.\nplugins {\n alias("
},
{
"path": "gradle/libs.versions.toml",
"chars": 5830,
"preview": "[versions]\n# Plugins\nagp = \"8.8.1\"\nkotlin = \"2.0.21\"\n\n# AndroidX\nandroidx-core-ktx = \"1.15.0\"\nandroidx-core-splashscreen"
},
{
"path": "gradle/wrapper/gradle-wrapper.properties",
"chars": 233,
"preview": "#Wed Aug 09 19:03:47 EDT 2023\ndistributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://"
},
{
"path": "gradle.properties",
"chars": 1412,
"preview": "# Project-wide Gradle settings.\n# IDE (e.g. Android Studio) users:\n# Gradle settings configured through the IDE *will ov"
},
{
"path": "gradlew",
"chars": 5766,
"preview": "#!/usr/bin/env sh\n\n#\n# Copyright 2015 the original author or authors.\n#\n# Licensed under the Apache License, Version 2.0"
},
{
"path": "gradlew.bat",
"chars": 2674,
"preview": "@rem\n@rem Copyright 2015 the original author or authors.\n@rem\n@rem Licensed under the Apache License, Version 2.0 (the \""
},
{
"path": "settings.gradle.kts",
"chars": 167,
"preview": "pluginManagement {\n repositories {\n google()\n mavenCentral()\n gradlePluginPortal()\n }\n}\n\nroot"
}
]
// ... and 2 more files (download for full content)
About this extraction
This page contains the full source code of the bunny-mod/BunnyManager GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 183 files (669.3 KB), approximately 173.5k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.